@deeeed/metamask-harness 0.16.0 → 0.17.1

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 (119) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +90 -137
  3. package/adapters/core/cleanup.sh +0 -0
  4. package/adapters/core/inject.sh +0 -0
  5. package/adapters/extension/cleanup.mjs +0 -0
  6. package/adapters/extension/console-tail.mjs +51 -24
  7. package/adapters/extension/ensure-browser.sh +0 -0
  8. package/adapters/extension/inject.mjs +5 -9
  9. package/adapters/extension/launch-browser.cjs +0 -0
  10. package/adapters/extension/launch-webpack.cjs +83 -0
  11. package/adapters/extension/launch.sh +8 -11
  12. package/adapters/extension/live.sh +2 -0
  13. package/adapters/extension/readiness.mjs +0 -0
  14. package/adapters/extension/reattach.sh +1 -0
  15. package/adapters/extension/refresh-build.sh +0 -0
  16. package/adapters/extension/seed-fixture.sh +4 -12
  17. package/adapters/extension/sidepanel-toggle.sh +4 -1
  18. package/adapters/extension/snapshot-dist.sh +0 -0
  19. package/adapters/extension/stamp-runtime-title.cjs +49 -0
  20. package/adapters/extension/start-watch.sh +16 -4
  21. package/adapters/extension/stop-viewers.sh +1 -1
  22. package/adapters/extension/sync-webpack-dist.cjs +105 -0
  23. package/adapters/extension/verify.sh +1 -1
  24. package/adapters/extension/wallet-fixture-state.cjs +0 -0
  25. package/adapters/manifest.json +46 -38
  26. package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +0 -0
  27. package/adapters/mobile/bridge-runtime/console-forwarder.cjs +1 -1
  28. package/adapters/mobile/bridge-runtime/lib/target-discovery.cjs +2 -3
  29. package/adapters/mobile/bridge-runtime/setup-wallet.sh +0 -0
  30. package/adapters/mobile/cleanup.sh +16 -176
  31. package/adapters/mobile/inject.sh +88 -671
  32. package/adapters/mobile/launch-console-forwarder.cjs +39 -0
  33. package/adapters/mobile/lib/metro-listener.sh +0 -0
  34. package/adapters/mobile/lib/tmux-viewer.sh +0 -0
  35. package/adapters/mobile/open-device.sh +0 -0
  36. package/adapters/mobile/prewarm-bundle.sh +0 -0
  37. package/adapters/mobile/start-console-forwarder.sh +70 -0
  38. package/adapters/mobile/start-metro.sh +0 -41
  39. package/adapters/mobile/stop-metro.sh +0 -0
  40. package/adapters/mobile/verify.sh +4 -61
  41. package/adapters/mobile/wait-for-bridge.sh +0 -0
  42. package/adapters/mobile/yarn-setup.sh +0 -0
  43. package/adapters/shared/activate-repo-node.sh +0 -0
  44. package/adapters/shared/activate-repo-ruby.sh +0 -0
  45. package/adapters/shared/cli-ux.sh +0 -0
  46. package/adapters/shared/ensure-runner-deps.sh +0 -0
  47. package/adapters/shared/harness-path.sh +0 -0
  48. package/adapters/shared/hash-helpers.sh +0 -0
  49. package/adapters/shared/install-repo-deps.sh +0 -0
  50. package/adapters/shared/json-field.sh +0 -0
  51. package/adapters/shared/open-debug.mjs +90 -36
  52. package/adapters/shared/open-log-window.sh +0 -0
  53. package/adapters/shared/reap-checkout-metros.sh +0 -0
  54. package/adapters/shared/resolve-slot-ports-core.mjs +0 -6
  55. package/adapters/shared/resolve-slot-ports.mjs +0 -0
  56. package/adapters/shared/resolve-slot-ports.sh +0 -5
  57. package/adapters/shared/sync-wallet-fixture.sh +0 -0
  58. package/adapters/shared/tmux-session.sh +0 -0
  59. package/adapters/shared/tmux-viewer.sh +0 -0
  60. package/dist/adapters/extension/console-capture.js +84 -0
  61. package/dist/adapters/extension/runtime-decision.js +14 -2
  62. package/dist/adapters/extension/surface.js +3 -2
  63. package/dist/adapters/mobile/prepare.js +22 -7
  64. package/dist/adapters/mobile/surface.js +1 -1
  65. package/dist/adapters/resolve-slot-ports.js +0 -2
  66. package/dist/adapters/slot-ports.js +8 -0
  67. package/dist/cli.js +5 -11
  68. package/dist/command-contract.js +441 -0
  69. package/dist/command-journal.js +225 -0
  70. package/dist/commands/call.js +43 -20
  71. package/dist/commands/check.js +9 -3
  72. package/dist/commands/debug.js +38 -31
  73. package/dist/commands/device-target.js +27 -12
  74. package/dist/commands/doctor.js +19 -6
  75. package/dist/commands/fixtures.js +19 -11
  76. package/dist/commands/last.js +52 -0
  77. package/dist/commands/launch/index.js +166 -59
  78. package/dist/commands/logs.js +8 -6
  79. package/dist/commands/manifest.js +114 -29
  80. package/dist/commands/parse-args.js +1 -0
  81. package/dist/commands/provision.js +10 -3
  82. package/dist/commands/run-engine.js +27 -42
  83. package/dist/commands/run-report.js +1 -1
  84. package/dist/commands/run.js +243 -41
  85. package/dist/commands/shared.js +11 -1
  86. package/dist/commands/status-probe.js +1 -1
  87. package/dist/commands/status.js +1 -1
  88. package/dist/commands/stop.js +7 -2
  89. package/dist/doctor.js +1 -4
  90. package/dist/harness.js +19 -125
  91. package/dist/json-stream.js +57 -0
  92. package/dist/live-adapter-contract.js +6 -20
  93. package/dist/mm-harness-cli.js +111 -41
  94. package/dist/paths.js +2 -5
  95. package/dist/run-diagnostics.js +35 -40
  96. package/dist/runner.js +32 -1
  97. package/dist/runtime-context.js +26 -8
  98. package/docs/CHEATSHEET.md +39 -52
  99. package/docs/CLI-ERGONOMICS-AUDIT.md +32 -0
  100. package/docs/CLI-ERGONOMICS-HUMAN-QA.md +48 -0
  101. package/docs/CODE-MAP.md +1 -1
  102. package/docs/UX-PRINCIPLES.md +2 -0
  103. package/docs/VIDEO-DEMO-VALIDATION.md +62 -0
  104. package/docs/architecture.md +65 -375
  105. package/docs/live-adapter-contract.md +3 -1
  106. package/library/actions/core/perps/_controller.mjs +10 -55
  107. package/library/manifests/mobile.action-manifest.json +1 -1
  108. package/package.json +3 -2
  109. package/scripts/completions.sh +0 -0
  110. package/scripts/install-completions.sh +0 -0
  111. package/adapters/mobile/overlay/app/dev-tools/AgenticService/AgentStepHud.tsx.patch +0 -185
  112. package/adapters/mobile/overlay/app/dev-tools/AgenticService/AgenticService.ts.patch +0 -1662
  113. package/adapters/mobile/overlay/compat/README.md +0 -28
  114. package/adapters/mobile/overlay/compat/rn81-message-event-source.patch +0 -42
  115. package/adapters/shared/resolve-farmslot-ports-core.mjs +0 -3
  116. package/adapters/shared/resolve-farmslot-ports.mjs +0 -5
  117. package/adapters/shared/resolve-farmslot-ports.sh +0 -7
  118. package/docs/CLI-SPEC.md +0 -1102
  119. package/docs/MENTAL-MODEL.md +0 -295
@@ -81,7 +81,7 @@ async function handleDoctor({ options }) {
81
81
  surface.resolveSlotPorts(target);
82
82
  const dtResult = applyDeviceTargeting("doctor", adapter, options, { gate: false, rerun: "" });
83
83
  if ("code" in dtResult) {
84
- return usageOut(json, "doctor", dtResult.message, "mm-harness doctor --fix --adapter mobile --device <adb-serial|simulator-udid>");
84
+ return usageOut(json, "doctor", dtResult.message, dtResult.userAction);
85
85
  }
86
86
  applyDoctorRuntimePorts(options);
87
87
  const lock = acquireCheckoutLock(target, "doctor-fix");
@@ -98,7 +98,14 @@ async function handleDoctor({ options }) {
98
98
  const result2 = createDoctorReport(adapter, target, manifestValidation, actionManifestPath);
99
99
  const nextActions = doctorFixNextActions(failed, result2.fixture, adapter, target);
100
100
  const status = result2.status === "pass" && failed.length === 0 ? "pass" : "fail";
101
- if (json) console.log(JSON.stringify({ ...result2, status, fixed, failed, nextActions }, null, 2));
101
+ const retryDoctor = `mm-harness doctor --fix --adapter ${adapter} --target ${shellQuote(target)} --json`;
102
+ const fixUserAction = failed.includes("wallet-fixture") ? `choose one wallet fixture source from nextActions, run it, then retry: ${retryDoctor}` : nextActions[0] ?? retryDoctor;
103
+ const error = status === "fail" ? {
104
+ code: "DOCTOR_FIX_INCOMPLETE",
105
+ message: "doctor --fix could not make every required check pass",
106
+ userAction: fixUserAction
107
+ } : void 0;
108
+ if (json) console.log(JSON.stringify({ ...result2, status, fixed, failed, nextActions, ...error ? { error } : {} }, null, 2));
102
109
  else {
103
110
  console.log(`${status} ${adapter} ${result2.compatibilityMode} manifest=${actionManifestPath} fixed=[${fixed.join(",")}] failed=[${failed.join(",")}]`);
104
111
  for (const action of nextActions) console.error(` Next: ${action}`);
@@ -113,8 +120,9 @@ async function handleDoctor({ options }) {
113
120
  surface.resolveSlotPorts(target);
114
121
  const dtResult = applyDeviceTargeting("doctor", adapter, options, { gate: false, rerun: "" });
115
122
  if ("code" in dtResult) {
116
- if (json) console.log(JSON.stringify({ schemaVersion: 1, command: "doctor", adapter, target, error: { code: dtResult.code, message: dtResult.message } }, null, 2));
117
- else console.error(`\u2717 doctor: ${dtResult.message}`);
123
+ if (json) console.log(JSON.stringify({ schemaVersion: 1, command: "doctor", adapter, target, error: { code: dtResult.code, message: dtResult.message, userAction: dtResult.userAction } }, null, 2));
124
+ else console.error(`\u2717 doctor: ${dtResult.message}
125
+ Next: ${dtResult.userAction}`);
118
126
  return EXIT.usage;
119
127
  }
120
128
  applyDoctorRuntimePorts(options);
@@ -151,7 +159,9 @@ async function handleDoctor({ options }) {
151
159
  printReady
152
160
  );
153
161
  }
154
- if (json) console.log(JSON.stringify({ ...doctorResult, ready: runtime?.decision === "ready", runtime, orphanMetros, capture, devices, additionalReachableDevices }, null, 2));
162
+ const doctorUserAction = `mm-harness doctor --fix --adapter ${adapter} --target ${shellQuote(target)} --json`;
163
+ const doctorError = doctorResult.status === "fail" ? { code: "DOCTOR_CHECKS_FAILED", message: "one or more required doctor checks failed", userAction: doctorUserAction } : void 0;
164
+ if (json) console.log(JSON.stringify({ ...doctorResult, ready: runtime?.decision === "ready", runtime, orphanMetros, capture, devices, additionalReachableDevices, ...doctorError ? { error: doctorError } : {} }, null, 2));
155
165
  else {
156
166
  const out = (style, text) => color(style, text, { stream: process.stdout });
157
167
  const stateStyle = (value, good) => value === good ? "ok" : "warn";
@@ -194,6 +204,7 @@ async function handleDoctor({ options }) {
194
204
  if (additionalReachableDevices.length > 0) renderAdditionalReachableDevices(additionalReachableDevices, out);
195
205
  if (liveView) renderMobileLiveBlock(deviceView?.devices ?? [], liveView.liveMap, out);
196
206
  }
207
+ if (doctorError) console.log(` ${out("dim", `Next: ${doctorUserAction}`)}`);
197
208
  }
198
209
  return doctorResult.status === "pass" ? 0 : 1;
199
210
  }
@@ -256,7 +267,9 @@ function emitExpectLive(adapter, target, runtime, result, orphanMetros, capture,
256
267
  return EXIT.runtime;
257
268
  }
258
269
  if (json) {
259
- console.log(JSON.stringify({ ...result, ready: live, runtime, orphanMetros, capture, devices, additionalReachableDevices }, null, 2));
270
+ const userAction = adapter === "extension" ? `mm-harness launch --adapter extension --target ${shellQuote(target)}` : adapter === "core" ? `mm-harness verify --adapter core --target ${shellQuote(target)}` : `mm-harness status --target ${shellQuote(target)} --json`;
271
+ const error = live ? void 0 : { code: "RUNTIME_NOT_LIVE", message: `${adapter} runtime is not live`, userAction };
272
+ console.log(JSON.stringify({ ...result, ready: live, runtime, orphanMetros, capture, devices, additionalReachableDevices, ...error ? { error } : {} }, null, 2));
260
273
  return live ? EXIT.ok : EXIT.runtime;
261
274
  }
262
275
  const out = (style, text) => color(style, text, { stream: process.stdout });
@@ -56,6 +56,14 @@ async function handleFixturesLocked(argv, deps) {
56
56
  if (sub !== "init" && sub !== "sync" && sub !== "set" && sub !== "generate" && sub !== "finalize") {
57
57
  return usageOut(json, "fixtures", "fixtures requires a subcommand: mm-harness fixtures <init|sync|set|generate|finalize>", "mm-harness fixtures init --from <path> or mm-harness fixtures init --dev");
58
58
  }
59
+ if (sub !== "init" && (flag(options, "dev") || flag(options, "force") || str(options, "from"))) {
60
+ return usageOut(
61
+ json,
62
+ "fixtures",
63
+ "--dev, --from, and --force belong to fixtures init; they do not apply a fixture.",
64
+ "mm-harness fixtures init --dev && mm-harness fixtures set"
65
+ );
66
+ }
59
67
  const target = targetOf(options);
60
68
  const adapter = resolveAdapter(options, target);
61
69
  if (!adapter) {
@@ -65,7 +73,7 @@ async function handleFixturesLocked(argv, deps) {
65
73
  surface.resolveSlotPorts(target);
66
74
  const dtResult = applyDeviceTargeting("fixtures", adapter, options, { gate: false, rerun: "" });
67
75
  if ("code" in dtResult) {
68
- return usageOut(json, "fixtures", dtResult.message, "mm-harness fixtures set --adapter mobile --device <adb-serial|simulator-udid>");
76
+ return usageOut(json, "fixtures", dtResult.message, dtResult.userAction);
69
77
  }
70
78
  if (sub === "generate") return fixturesGenerate(adapter, target, options, json);
71
79
  if (sub === "finalize") return fixturesFinalize(adapter, target, options, json);
@@ -96,6 +104,14 @@ async function handleFixturesLocked(argv, deps) {
96
104
  return exitCode;
97
105
  }
98
106
  const fixturePath = path.resolve(str(options, "fixture") ?? process.env.RECIPE_WALLET_FIXTURE ?? canonicalFixture);
107
+ if (!fs.existsSync(fixturePath)) {
108
+ return usageOut(
109
+ json,
110
+ "fixtures",
111
+ `no wallet fixture at ${fixturePath}.`,
112
+ "mm-harness fixtures init --dev && mm-harness fixtures set"
113
+ );
114
+ }
99
115
  process.stderr.write(`\u2192 fixtures set ${adapter} \u2014 connecting bridge + applying wallet fixture (can take ~30s)\u2026
100
116
  `);
101
117
  let status;
@@ -129,14 +145,6 @@ async function handleFixturesLocked(argv, deps) {
129
145
  console.error(` Next: ${retryHint}`);
130
146
  }
131
147
  } else {
132
- if (!fs.existsSync(fixturePath)) {
133
- return usageOut(
134
- json,
135
- "fixtures",
136
- `no wallet fixture at ${fixturePath}.`,
137
- "create it (or pass --fixture <path>), then re-run: mm-harness fixtures set"
138
- );
139
- }
140
148
  const previousFixtureEnv = process.env.RECIPE_WALLET_FIXTURE;
141
149
  process.env.RECIPE_WALLET_FIXTURE = fixturePath;
142
150
  try {
@@ -150,7 +158,7 @@ async function handleFixturesLocked(argv, deps) {
150
158
  console.error(` Next: ${retryHint}`);
151
159
  }
152
160
  }
153
- const teaching = `Wallet fixture applied. Want different accounts? Edit: ${canonicalFixture}`;
161
+ const teaching = status === "pass" ? `Wallet fixture applied. Want different accounts? Edit: ${canonicalFixture}` : "Wallet fixture was not applied.";
154
162
  if (json) {
155
163
  console.log(
156
164
  JSON.stringify(
@@ -174,7 +182,7 @@ async function handleFixturesLocked(argv, deps) {
174
182
  2
175
183
  )
176
184
  );
177
- } else {
185
+ } else if (status === "pass") {
178
186
  console.error(teaching);
179
187
  }
180
188
  return status === "pass" ? EXIT.ok : EXIT.runtime;
@@ -0,0 +1,52 @@
1
+ import { readCommandJournal } from "../command-journal.js";
2
+ import { EXIT } from "./shared.js";
3
+ import {
4
+ optionFlag,
5
+ optionString,
6
+ targetPath
7
+ } from "./parse-args.js";
8
+ async function handleLast({ options }) {
9
+ const target = targetPath(options);
10
+ const { file, record } = readCommandJournal(target, optionString(options, "runtimeDir"));
11
+ const json = optionFlag(options, "json");
12
+ if (!record) {
13
+ const message = `no resumability journal exists for ${target}`;
14
+ const userAction = "run a proof or runtime command in this checkout, then re-run mm-harness last --json";
15
+ if (json) {
16
+ console.log(JSON.stringify({
17
+ schemaVersion: 1,
18
+ command: "last",
19
+ target,
20
+ journalPath: file,
21
+ status: "fail",
22
+ exitCode: EXIT.runtime,
23
+ error: { code: "LAST_NOT_FOUND", message, userAction }
24
+ }, null, 2));
25
+ } else {
26
+ console.error(`\u2717 mm-harness last: ${message}
27
+ Next: ${userAction}`);
28
+ }
29
+ return EXIT.runtime;
30
+ }
31
+ if (json) {
32
+ console.log(JSON.stringify({
33
+ schemaVersion: 1,
34
+ command: "last",
35
+ target,
36
+ journalPath: file,
37
+ status: "pass",
38
+ exitCode: EXIT.ok,
39
+ last: record
40
+ }, null, 2));
41
+ } else {
42
+ const end = record.finishedAt ?? "still running or interrupted";
43
+ console.log(`${record.verdict.toUpperCase()} ${record.command} (exit ${record.exitCode ?? "pending"})`);
44
+ console.log(`started: ${record.startedAt}`);
45
+ console.log(`finished: ${end}`);
46
+ if (record.evidencePaths.length > 0) console.log(`evidence: ${record.evidencePaths.join(", ")}`);
47
+ }
48
+ return EXIT.ok;
49
+ }
50
+ export {
51
+ handleLast
52
+ };
@@ -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,8 @@ 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";
38
+ import { ensureExtensionConsoleCapture } from "../../adapters/extension/console-capture.js";
36
39
  const LAUNCH_BOOLEANS = /* @__PURE__ */ new Set([
37
40
  "build",
38
41
  "watch",
@@ -40,30 +43,58 @@ const LAUNCH_BOOLEANS = /* @__PURE__ */ new Set([
40
43
  "sidepanel",
41
44
  "fullscreen",
42
45
  "runway",
43
- "json"
46
+ "json",
47
+ "jsonStream"
44
48
  ]);
45
49
  const DEFAULT_EXTENSION_DAPP_URL = "https://metamask.github.io/test-dapp/";
46
50
  async function handleLaunch(argv) {
47
51
  const { options } = parseFlags(argv, LAUNCH_BOOLEANS);
48
52
  const json = flag(options, "json");
53
+ const stream = new JsonStreamWriter("launch", flag(options, "jsonStream"));
54
+ const jsonOutput = json && !stream.enabled;
55
+ const restoreStdout = stream.isolateStdout();
49
56
  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
57
  try {
56
- return await handleLaunchLocked(argv);
58
+ let exitCode;
59
+ if (!fs.existsSync(target)) {
60
+ exitCode = await handleLaunchLocked(argv, stream);
61
+ } else {
62
+ const lock = acquireCheckoutLock(target, "launch");
63
+ if ("message" in lock) {
64
+ const userAction = `wait for the current owner, or inspect ${lock.path} if its process has exited`;
65
+ stream.error({ code: "SANDBOX_BUSY", message: lock.message, userAction });
66
+ exitCode = checkoutBusyOut(jsonOutput, "launch", lock.message, lock.path);
67
+ } else {
68
+ try {
69
+ exitCode = await handleLaunchLocked(argv, stream);
70
+ } finally {
71
+ lock.release();
72
+ }
73
+ }
74
+ }
75
+ stream.complete(exitCode === EXIT.ok ? "pass" : "fail", exitCode);
76
+ return exitCode;
77
+ } catch (error) {
78
+ const exitCode = error !== null && typeof error === "object" && "exitCode" in error && typeof error.exitCode === "number" ? error.exitCode : EXIT.runtime;
79
+ stream.error({
80
+ code: exitCode === EXIT.usage ? "CLI_USAGE_ERROR" : "LAUNCH_FAILED",
81
+ message: error instanceof Error ? error.message : String(error),
82
+ userAction: `mm-harness doctor --target ${shellQuote(target)} --json`
83
+ });
84
+ stream.complete("fail", exitCode);
85
+ throw error;
57
86
  } finally {
58
- lock.release();
87
+ restoreStdout();
59
88
  }
60
89
  }
61
- async function handleLaunchLocked(argv) {
90
+ async function handleLaunchLocked(argv, stream) {
62
91
  const { positional, options } = parseFlags(argv, LAUNCH_BOOLEANS);
63
92
  const json = flag(options, "json");
93
+ const jsonOutput = json && !stream.enabled;
94
+ const machine = json || stream.enabled;
64
95
  const target = targetOf(options);
65
96
  const heal = parseHeal(options, "auto");
66
- if (typeof heal !== "string") return usageOut(json, "launch", heal.error, "use --heal off|infra-only|auto");
97
+ if (typeof heal !== "string") return launchUsage(jsonOutput, stream, heal.error, "use --heal off|infra-only|auto");
67
98
  const posToken = positional[0];
68
99
  const mobileTargetToken = posToken === "ios" || posToken === "android" ? posToken : void 0;
69
100
  const platformFlag = str(options, "platform");
@@ -72,15 +103,16 @@ async function handleLaunchLocked(argv) {
72
103
  const adapterHint = mobileTarget ? "mobile" : void 0;
73
104
  const adapter = resolveAdapter(options, target, adapterHint);
74
105
  if (!adapter) {
75
- return usageOut(json, "launch", `could not detect the MetaMask repo type for ${target}`, ADAPTER_DETECT_NEXT);
106
+ return launchUsage(jsonOutput, stream, `could not detect the MetaMask repo type for ${target}`, ADAPTER_DETECT_NEXT);
76
107
  }
108
+ stream.phase("resolve", { target, adapter, platform: mobileTarget ?? null });
77
109
  if (adapter === "core") {
78
- return usageOut(json, "launch", "core is headless; there is nothing to launch.", "mm-harness verify");
110
+ return launchUsage(jsonOutput, stream, "core is headless; there is nothing to launch.", "mm-harness verify");
79
111
  }
80
112
  if (adapter === "mobile" && !mobileTarget) {
81
- return usageOut(
82
- json,
83
- "launch",
113
+ return launchUsage(
114
+ jsonOutput,
115
+ stream,
84
116
  "target is required for mobile.",
85
117
  "mm-harness launch ios or mm-harness launch android"
86
118
  );
@@ -90,15 +122,15 @@ async function handleLaunchLocked(argv) {
90
122
  const wantWatch = flag(options, "watch");
91
123
  const wantRunway = flag(options, "runway");
92
124
  if (wantRunway && adapter !== "mobile") {
93
- return usageOut(json, "launch", "runway is mobile-only.", "drop --runway for the extension");
125
+ return launchUsage(jsonOutput, stream, "runway is mobile-only.", "drop --runway for the extension");
94
126
  }
95
127
  const displayMode = flag(options, "sidepanel") && !flag(options, "fullscreen") ? "sidepanel" : "fullscreen";
96
128
  for (const portFlag of ["cdpPort", "watcherPort"]) {
97
129
  const value = str(options, portFlag);
98
130
  if (value !== void 0 && !/^\d+$/u.test(value)) {
99
- return usageOut(
100
- json,
101
- "launch",
131
+ return launchUsage(
132
+ jsonOutput,
133
+ stream,
102
134
  `--${portFlag === "cdpPort" ? "cdp-port" : "watcher-port"} must be numeric (got: ${value}).`,
103
135
  `pass a numeric port, e.g. --${portFlag === "cdpPort" ? "cdp-port 6663" : "watcher-port 8081"}`
104
136
  );
@@ -106,7 +138,7 @@ async function handleLaunchLocked(argv) {
106
138
  }
107
139
  const envResult = applyLaunchEnvOverrides(options, adapter, mobileTarget, target);
108
140
  if (envResult && "code" in envResult) {
109
- return usageOut(json, "launch", envResult.message, "mm-harness launch android --device <adb-serial|device-name>");
141
+ return launchUsage(jsonOutput, stream, envResult.message, envResult.userAction);
110
142
  }
111
143
  const tier = wantBuild ? "build" : "quick";
112
144
  if (adapter === "extension") {
@@ -115,44 +147,55 @@ async function handleLaunchLocked(argv) {
115
147
  process.env.EXTENSION_START_URL = requestedUrl || DEFAULT_EXTENSION_DAPP_URL;
116
148
  }
117
149
  }
118
- if (!json && adapter === "extension") {
150
+ if (!machine && adapter === "extension") {
119
151
  const modeNote = displayMode === "sidepanel" ? `sidepanel \xB7 dapp ${process.env.EXTENSION_START_URL ?? DEFAULT_EXTENSION_DAPP_URL}` : "fullscreen";
120
152
  const workNote = wantWatch ? "watch only" : tier === "build" ? "clean build" : "quick reuse probe";
121
153
  console.error(
122
154
  `\u2192 extension launch \u2014 ${modeNote} \xB7 CDP :${process.env.CDP_PORT ?? "default"} \xB7 ${workNote}`
123
155
  );
124
156
  }
157
+ if (adapter === "mobile") {
158
+ 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";
159
+ const watcherPort = str(options, "watcherPort") ?? process.env.WATCHER_PORT ?? "auto";
160
+ writeInteractiveProgress(
161
+ machine,
162
+ `\u2192 mobile launch \u2014 ${mobileTarget} \xB7 ${tier === "build" ? "native build" : "quick readiness"} \xB7 ${device} \xB7 Metro ${watcherPort === "auto" ? "auto" : `:${watcherPort}`}`
163
+ );
164
+ }
125
165
  const state = newHealState();
126
166
  if (tier === "quick" && nativeInputsChanged(target, adapter)) {
127
- return usageOut(
128
- json,
129
- "launch",
167
+ return launchUsage(
168
+ jsonOutput,
169
+ stream,
130
170
  "native build inputs changed since last build.",
131
171
  `mm-harness launch ${adapter === "mobile" ? `${mobileTarget} ` : ""}--build`
132
172
  );
133
173
  }
134
- const ensured = await ensureOverlay(adapter, target, heal, state, json);
174
+ stream.phase("install");
175
+ const ensured = await ensureOverlay(adapter, target, heal, state, machine);
135
176
  if (!ensured.ok) {
136
- return launchFail(json, adapter, mobileTarget, tier, state, {
177
+ return launchFail(jsonOutput, stream, adapter, mobileTarget, tier, state, target, {
137
178
  code: "OVERLAY_INSTALL_FAILED",
138
179
  message: ensured.error ?? "overlay install failed",
139
180
  recoverable: false,
140
181
  exitCode: EXIT.infra
141
182
  });
142
183
  }
184
+ stream.phase("healthcheck");
143
185
  if (adapter === "extension") {
144
186
  const willBuild = wantWatch || tier === "build" || !await extensionRuntimeReusable(target);
145
187
  if (willBuild) {
146
188
  const depsBlock = extensionDepsBlock(target);
147
189
  if (depsBlock) {
148
190
  if (heal === "off") {
149
- return usageOut(json, "launch", depsBlock.message, depsBlock.userAction);
191
+ return launchUsage(jsonOutput, stream, depsBlock.message, depsBlock.userAction);
150
192
  }
151
193
  const recoveryCode2 = "deps.installed";
152
194
  state.attemptedRecoveries.push(recoveryCode2);
195
+ stream.phase("recover");
153
196
  const repaired = await installExtensionDeps(target);
154
197
  if (repaired.status !== 0 || extensionDepsBlock(target)) {
155
- return launchFail(json, adapter, mobileTarget, tier, state, {
198
+ return launchFail(jsonOutput, stream, adapter, mobileTarget, tier, state, target, {
156
199
  code: "DEPENDENCY_INSTALL_FAILED",
157
200
  message: depsBlock.message,
158
201
  recoverable: false,
@@ -166,12 +209,13 @@ async function handleLaunchLocked(argv) {
166
209
  }
167
210
  }
168
211
  }
169
- let attempt = await executeComposition(adapter, mobileTarget, tier, wantWatch, target, json, displayMode);
212
+ stream.phase("launch");
213
+ let attempt = await executeComposition(adapter, mobileTarget, tier, wantWatch, target, machine, displayMode);
170
214
  if (attempt.status === 0) {
171
- return await finishLaunch(json, adapter, mobileTarget, tier, displayMode, target, state, wantWatch, wantVerify);
215
+ return await finishLaunch(jsonOutput, machine, stream, adapter, mobileTarget, tier, displayMode, target, state, wantWatch, wantVerify);
172
216
  }
173
217
  if (heal === "off") {
174
- return launchFail(json, adapter, mobileTarget, tier, state, {
218
+ return launchFail(jsonOutput, stream, adapter, mobileTarget, tier, state, target, {
175
219
  code: "LAUNCH_FAILED",
176
220
  message: `launch ${adapter}${mobileTarget ? ` ${mobileTarget}` : ""} failed (healing off)`,
177
221
  recoverable: false,
@@ -181,12 +225,13 @@ async function handleLaunchLocked(argv) {
181
225
  if (adapter === "extension" && tier === "quick" && extensionQuickReattachFailed(attempt.output)) {
182
226
  const recoveryCode2 = "chrome.relaunched";
183
227
  state.attemptedRecoveries.push(recoveryCode2);
184
- const rebuildAttempt = await executeComposition(adapter, mobileTarget, "build", wantWatch, target, json, displayMode);
228
+ stream.phase("recover");
229
+ const rebuildAttempt = await executeComposition(adapter, mobileTarget, "build", wantWatch, target, machine, displayMode);
185
230
  if (rebuildAttempt.status === 0) {
186
231
  state.recovered.push(recoveryCode2);
187
- return await finishLaunch(json, adapter, mobileTarget, tier, displayMode, target, state, wantWatch, wantVerify);
232
+ return await finishLaunch(jsonOutput, machine, stream, adapter, mobileTarget, tier, displayMode, target, state, wantWatch, wantVerify);
188
233
  }
189
- return launchFail(json, adapter, mobileTarget, tier, state, {
234
+ return launchFail(jsonOutput, stream, adapter, mobileTarget, tier, state, target, {
190
235
  code: "EXTENSION_RELAUNCH_FAILED",
191
236
  message: "quick reattach failed, and clean relaunch failed too.",
192
237
  recoverable: false,
@@ -195,7 +240,7 @@ async function handleLaunchLocked(argv) {
195
240
  });
196
241
  }
197
242
  if (adapter === "mobile" && mobileProvisioningBlocked(attempt.output)) {
198
- return launchFail(json, adapter, mobileTarget, tier, state, {
243
+ return launchFail(jsonOutput, stream, adapter, mobileTarget, tier, state, target, {
199
244
  code: "MOBILE_PROVISION_REQUIRED",
200
245
  message: `mobile ${mobileTarget ?? "runtime"} is not provisioned for this slot.`,
201
246
  recoverable: false,
@@ -206,7 +251,7 @@ async function handleLaunchLocked(argv) {
206
251
  }
207
252
  const bound = checkHealBounds(target, attempt.output, state);
208
253
  if (bound !== null) {
209
- return launchFail(json, adapter, mobileTarget, tier, state, {
254
+ return launchFail(jsonOutput, stream, adapter, mobileTarget, tier, state, target, {
210
255
  code: bound.code,
211
256
  message: bound.message,
212
257
  recoverable: false,
@@ -217,12 +262,13 @@ async function handleLaunchLocked(argv) {
217
262
  }
218
263
  const recoveryCode = RECOVERY_CODE[adapter];
219
264
  state.attemptedRecoveries.push(recoveryCode);
220
- attempt = await executeComposition(adapter, mobileTarget, tier, wantWatch, target, json, displayMode);
265
+ stream.phase("recover");
266
+ attempt = await executeComposition(adapter, mobileTarget, tier, wantWatch, target, machine, displayMode);
221
267
  if (attempt.status === 0) {
222
268
  state.recovered.push(recoveryCode);
223
- return await finishLaunch(json, adapter, mobileTarget, tier, displayMode, target, state, wantWatch, wantVerify);
269
+ return await finishLaunch(jsonOutput, machine, stream, adapter, mobileTarget, tier, displayMode, target, state, wantWatch, wantVerify);
224
270
  }
225
- return launchFail(json, adapter, mobileTarget, tier, state, {
271
+ return launchFail(jsonOutput, stream, adapter, mobileTarget, tier, state, target, {
226
272
  code: "SAME_RECOVERY_TWICE",
227
273
  message: `launch ${adapter}${mobileTarget ? ` ${mobileTarget}` : ""} failed again after ${recoveryCode} recovery \u2014 refusing to loop.`,
228
274
  recoverable: false,
@@ -275,14 +321,16 @@ function applyLaunchEnvOverrides(options, adapter, mobileTarget, target) {
275
321
  return {
276
322
  ok: false,
277
323
  code: "DEVICE_WRONG_PLATFORM",
278
- message: `--device ${device} did not resolve to an Android device for launch android.`
324
+ message: `--device ${device} did not resolve to an Android device for launch android.`,
325
+ userAction: `mm-harness status --target ${shellQuote(target)} --all-devices --json`
279
326
  };
280
327
  }
281
328
  if (mobileTarget === "ios" && device && !process.env.IOS_SIMULATOR) {
282
329
  return {
283
330
  ok: false,
284
331
  code: "DEVICE_WRONG_PLATFORM",
285
- message: `--device ${device} did not resolve to an iOS simulator for launch ios.`
332
+ message: `--device ${device} did not resolve to an iOS simulator for launch ios.`,
333
+ userAction: `mm-harness status --target ${shellQuote(target)} --all-devices --json`
286
334
  };
287
335
  }
288
336
  }
@@ -328,26 +376,29 @@ async function executeComposition(adapter, mobileTarget, tier, wantWatch, target
328
376
  }
329
377
  return launchExtension(target, tier, wantWatch, displayMode);
330
378
  }
331
- async function finishLaunch(json, adapter, mobileTarget, tier, displayMode, target, state, wantWatch, wantVerify, sidepanelRecoveryAllowed = true) {
379
+ async function finishLaunch(jsonOutput, machine, stream, adapter, mobileTarget, tier, displayMode, target, state, wantWatch, wantVerify, sidepanelRecoveryAllowed = true) {
332
380
  if (adapter === "extension" && displayMode === "sidepanel") {
333
381
  const sidepanelSh = path.join(runnerDir, "adapters/extension/sidepanel-toggle.sh");
334
382
  const sidepanelArgs = ["open"];
335
383
  if (process.env.CDP_PORT) sidepanelArgs.push("--cdp-port", process.env.CDP_PORT);
336
- if (!json) {
384
+ if (!machine) {
337
385
  console.error(
338
386
  `[sidepanel] opening panel beside ${process.env.EXTENSION_START_URL ?? DEFAULT_EXTENSION_DAPP_URL}`
339
387
  );
340
388
  }
341
- const sidepanel = spawnScript(sidepanelSh, sidepanelArgs, target, json, { REPO: target });
389
+ const sidepanel = spawnScript(sidepanelSh, sidepanelArgs, target, machine, { REPO: target });
342
390
  if (sidepanel.status !== 0) {
343
391
  const recoveryCode = "chrome.relaunched";
344
392
  if (sidepanelRecoveryAllowed && tier === "quick" && extensionRuntimeBlocked(sidepanel.output) && !state.attemptedRecoveries.includes(recoveryCode)) {
345
393
  state.attemptedRecoveries.push(recoveryCode);
346
- const rebuildAttempt = await executeComposition(adapter, mobileTarget, "build", wantWatch, target, json, displayMode);
394
+ stream.phase("recover");
395
+ const rebuildAttempt = await executeComposition(adapter, mobileTarget, "build", wantWatch, target, machine, displayMode);
347
396
  if (rebuildAttempt.status === 0) {
348
397
  state.recovered.push(recoveryCode);
349
398
  return await finishLaunch(
350
- json,
399
+ jsonOutput,
400
+ machine,
401
+ stream,
351
402
  adapter,
352
403
  mobileTarget,
353
404
  tier,
@@ -359,7 +410,7 @@ async function finishLaunch(json, adapter, mobileTarget, tier, displayMode, targ
359
410
  false
360
411
  );
361
412
  }
362
- return launchFail(json, adapter, mobileTarget, tier, state, {
413
+ return launchFail(jsonOutput, stream, adapter, mobileTarget, tier, state, target, {
363
414
  code: "EXTENSION_RELAUNCH_FAILED",
364
415
  message: "sidepanel open found a blocked extension runtime, and clean relaunch failed too.",
365
416
  recoverable: false,
@@ -367,7 +418,7 @@ async function finishLaunch(json, adapter, mobileTarget, tier, displayMode, targ
367
418
  originalError: rebuildAttempt.output.trim() || void 0
368
419
  });
369
420
  }
370
- return launchFail(json, adapter, mobileTarget, tier, state, {
421
+ return launchFail(jsonOutput, stream, adapter, mobileTarget, tier, state, target, {
371
422
  code: "SIDEPANEL_OPEN_FAILED",
372
423
  message: "app launched but opening the side panel failed.",
373
424
  recoverable: false,
@@ -377,16 +428,48 @@ async function finishLaunch(json, adapter, mobileTarget, tier, displayMode, targ
377
428
  });
378
429
  }
379
430
  }
431
+ if (adapter === "extension") {
432
+ try {
433
+ await ensureExtensionConsoleCapture(target);
434
+ } catch (error) {
435
+ if (!machine) {
436
+ console.error(`WARN: Extension console capture unavailable: ${error instanceof Error ? error.message : String(error)}`);
437
+ }
438
+ }
439
+ }
380
440
  if (wantVerify) {
441
+ stream.phase("verify");
381
442
  const verifyArgs = ["verify", "--adapter", adapter, "--target", target];
382
443
  if (adapter === "mobile" && mobileTarget) verifyArgs.push("--platform", mobileTarget);
383
- if (json) verifyArgs.push("--json");
384
- return handleHarness(verifyArgs);
444
+ if (machine) verifyArgs.push("--json");
445
+ const exitCode = await handleHarness(verifyArgs);
446
+ if (stream.enabled) {
447
+ for (const mutation of state.mutations) stream.mutation(mutation);
448
+ for (const recovery of state.recovered) stream.recovery(recovery);
449
+ stream.complete(exitCode === EXIT.ok ? "pass" : "fail", exitCode, {
450
+ adapter,
451
+ platform: mobileTarget ?? (adapter === "extension" ? displayMode : null),
452
+ tier,
453
+ recovered: state.recovered,
454
+ mutations: state.mutations
455
+ });
456
+ }
457
+ return exitCode;
385
458
  }
386
- return launchPass(json, adapter, mobileTarget, tier, displayMode, state);
459
+ return launchPass(jsonOutput, stream, adapter, mobileTarget, tier, displayMode, state);
387
460
  }
388
- function launchPass(json, adapter, mobileTarget, tier, displayMode, state) {
389
- if (json) {
461
+ function launchPass(json, stream, adapter, mobileTarget, tier, displayMode, state) {
462
+ if (stream.enabled) {
463
+ for (const mutation of state.mutations) stream.mutation(mutation);
464
+ for (const recovery of state.recovered) stream.recovery(recovery);
465
+ stream.complete("pass", EXIT.ok, {
466
+ adapter,
467
+ platform: mobileTarget ?? (adapter === "extension" ? displayMode : null),
468
+ tier,
469
+ recovered: state.recovered,
470
+ mutations: state.mutations
471
+ });
472
+ } else if (json) {
390
473
  console.log(
391
474
  JSON.stringify(
392
475
  {
@@ -424,8 +507,32 @@ function launchDeviceLabel(mobileTarget) {
424
507
  }
425
508
  return process.env.IOS_SIMULATOR || process.env.ADB_SERIAL || "booted device";
426
509
  }
427
- function launchFail(json, adapter, mobileTarget, tier, state, failure) {
428
- if (json) {
510
+ function launchUsage(json, stream, message, userAction) {
511
+ stream.error({ code: "USAGE", message, userAction });
512
+ return usageOut(json, "launch", message, userAction);
513
+ }
514
+ function launchFail(json, stream, adapter, mobileTarget, tier, state, target, failure) {
515
+ const userAction = failure.userAction ?? `mm-harness doctor --fix --adapter ${adapter} --target ${shellQuote(target)} --json`;
516
+ if (stream.enabled) {
517
+ for (const mutation of state.mutations) stream.mutation(mutation);
518
+ for (const recovery of state.recovered) stream.recovery(recovery);
519
+ stream.error({
520
+ code: failure.code,
521
+ message: failure.message,
522
+ retryable: failure.recoverable,
523
+ userAction,
524
+ originalError: failure.originalError ?? null
525
+ });
526
+ stream.complete("fail", failure.exitCode, {
527
+ adapter,
528
+ platform: mobileTarget ?? null,
529
+ tier,
530
+ recovered: state.recovered,
531
+ mutations: state.mutations,
532
+ recoverable: failure.recoverable,
533
+ attemptedRecoveries: state.attemptedRecoveries
534
+ });
535
+ } else if (json) {
429
536
  console.log(
430
537
  JSON.stringify(
431
538
  {
@@ -445,7 +552,7 @@ function launchFail(json, adapter, mobileTarget, tier, state, failure) {
445
552
  code: failure.code,
446
553
  message: failure.message,
447
554
  retryable: failure.recoverable,
448
- userAction: failure.userAction ?? null,
555
+ userAction,
449
556
  originalError: failure.originalError ?? null
450
557
  }
451
558
  },
@@ -458,8 +565,8 @@ function launchFail(json, adapter, mobileTarget, tier, state, failure) {
458
565
  `\u2717 launch ${adapter}${mobileTarget ? ` ${mobileTarget}` : ""} failed
459
566
  ${failure.message}` + (failure.originalError ? `
460
567
  --- original failure ---
461
- ${failure.originalError}` : "") + (failure.userAction ? `
462
- Next: ${failure.userAction}` : "")
568
+ ${failure.originalError}` : "") + `
569
+ Next: ${userAction}`
463
570
  );
464
571
  }
465
572
  return failure.exitCode;