@deeeed/metamask-harness 0.73.0 → 0.74.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.74.0 - 2026-10-03
6
+
7
+ ### Changed
8
+
9
+ - Adapters register in an `AdapterRegistry` (`src/adapters/registry.ts`) instead of a compiled-in union: the adapter id is a string, the four built-in surfaces register themselves, and `--adapter` resolves through the registry. `launch` moves behind a new `AdapterSurface.launch` member, so the command keeps only the shared grammar and no longer branches on the adapter name (39 such branches in `commands/launch/index.ts`, now 0). A test-only `example` adapter registers and launches with no shared-file edit. Flags, output, and the CLI goldens are unchanged.
10
+
5
11
  ## 0.73.0 - 2026-10-03
6
12
 
7
13
  ### Changed
@@ -1,6 +1,7 @@
1
1
  import path from "node:path";
2
2
  import { depsCheck } from "@farmslot/recipe-harness/runtime/deps-readiness";
3
3
  import { shellQuote } from "../../commands/parse-args.js";
4
+ import { adapterRegistry } from "../registry.js";
4
5
  const coreSurface = {
5
6
  adapter: "core",
6
7
  headless: true,
@@ -53,8 +54,10 @@ const coreSurface = {
53
54
  launch: "mm-harness run <recipe> # run recipes against the headless core",
54
55
  relaunch: "mm-harness verify",
55
56
  runtimeProbeRecovery: (target) => `mm-harness verify --adapter core --target ${shellQuote(path.resolve(target))}`
56
- }
57
+ },
58
+ launch: async ({ usage }) => usage("core is headless; there is nothing to launch.", "mm-harness verify")
57
59
  };
60
+ adapterRegistry.register(coreSurface);
58
61
  export {
59
62
  coreSurface
60
63
  };
@@ -9,6 +9,7 @@ import {
9
9
  } from "../slot-ports.js";
10
10
  import { decideExtensionReadiness } from "./runtime-decision.js";
11
11
  import { extensionProductConfigBlock } from "./product-config.js";
12
+ import { adapterRegistry } from "../registry.js";
12
13
  function watcherStatus(buildLog) {
13
14
  if (buildLog === "ok") return "up";
14
15
  if (buildLog === "no-watch") return "down";
@@ -98,8 +99,16 @@ const extensionSurface = {
98
99
  launch: "mm-harness launch",
99
100
  relaunch: "mm-harness launch --build",
100
101
  runtimeProbeRecovery: (target) => `mm-harness launch --adapter extension --target ${shellQuote(path.resolve(target))}`
102
+ },
103
+ launch: async (context) => {
104
+ const [{ runDeviceLaunch }, { extensionLaunchProfile }] = await Promise.all([
105
+ import("../../commands/launch/device.js"),
106
+ import("../../commands/launch/extension.js")
107
+ ]);
108
+ return runDeviceLaunch(context, extensionLaunchProfile);
101
109
  }
102
110
  };
111
+ adapterRegistry.register(extensionSurface);
103
112
  export {
104
113
  extensionRuntimeNextAction,
105
114
  extensionSurface
@@ -5,6 +5,7 @@ import { shellQuote } from "../../commands/parse-args.js";
5
5
  import { resolveMobileSlotPorts } from "../slot-ports.js";
6
6
  import { mobileRuntimeStatus } from "./prepare.js";
7
7
  import { hasRunwayProvisionBaseline, provisionRunwayMobile } from "./provision.js";
8
+ import { adapterRegistry } from "../registry.js";
8
9
  function mobileRuntimeNextAction(reasonCode, target, platform) {
9
10
  const resolved = shellQuote(path.resolve(target));
10
11
  return reasonCode === "deps-missing" || reasonCode === "deps-partial" || reasonCode === "deps-stale" ? `cd ${resolved} && yarn setup` : `mm-harness launch ${platform} --adapter mobile --target ${resolved}`;
@@ -59,8 +60,16 @@ const mobileSurface = {
59
60
  launch: "mm-harness launch ios",
60
61
  relaunch: "mm-harness launch ios",
61
62
  runtimeProbeRecovery: (target) => `mm-harness status --target ${shellQuote(path.resolve(target))} --json`
63
+ },
64
+ launch: async (context) => {
65
+ const [{ runDeviceLaunch }, { mobileLaunchProfile }] = await Promise.all([
66
+ import("../../commands/launch/device.js"),
67
+ import("../../commands/launch/mobile.js")
68
+ ]);
69
+ return runDeviceLaunch(context, mobileLaunchProfile);
62
70
  }
63
71
  };
72
+ adapterRegistry.register(mobileSurface);
64
73
  export {
65
74
  mobileRuntimeNextAction,
66
75
  mobileSurface
@@ -0,0 +1,17 @@
1
+ const surfaces = /* @__PURE__ */ new Map();
2
+ const adapterRegistry = {
3
+ register(surface) {
4
+ if (surfaces.has(surface.adapter)) throw new Error(`adapter '${surface.adapter}' is already registered`);
5
+ surfaces.set(surface.adapter, surface);
6
+ },
7
+ get(id) {
8
+ const surface = surfaces.get(id);
9
+ if (!surface) throw new Error(`unknown adapter '${id}' (registered: ${[...surfaces.keys()].join(", ")})`);
10
+ return surface;
11
+ },
12
+ has: (id) => surfaces.has(id),
13
+ list: () => [...surfaces.keys()]
14
+ };
15
+ export {
16
+ adapterRegistry
17
+ };
@@ -1,15 +1,10 @@
1
- import { coreSurface } from "./core/surface.js";
2
- import { extensionSurface } from "./extension/surface.js";
3
- import { mobileSurface } from "./mobile/surface.js";
4
- import { terminalSurface } from "./terminal/surface.js";
5
- const SURFACES = {
6
- mobile: mobileSurface,
7
- extension: extensionSurface,
8
- core: coreSurface,
9
- terminal: terminalSurface
10
- };
1
+ import { adapterRegistry } from "./registry.js";
2
+ import "./mobile/surface.js";
3
+ import "./extension/surface.js";
4
+ import "./core/surface.js";
5
+ import "./terminal/surface.js";
11
6
  function getAdapterSurface(adapter) {
12
- return SURFACES[adapter];
7
+ return adapterRegistry.get(adapter);
13
8
  }
14
9
  export {
15
10
  getAdapterSurface
@@ -0,0 +1,85 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import path from "node:path";
3
+ import { flag, str } from "../../commands/shared.js";
4
+ import { runnerDir } from "../../paths.js";
5
+ const TERMINAL_ONLY_LAUNCH_OPTIONS = Object.freeze({
6
+ signer: "--signer",
7
+ account: "--account",
8
+ freshProfile: "--fresh-profile",
9
+ headless: "--headless",
10
+ headful: "--headful",
11
+ slowMo: "--slow-mo",
12
+ network: "--network",
13
+ mainnetConfirmation: "--mainnet-confirmation"
14
+ });
15
+ function terminalOnlyLaunchFlags(options) {
16
+ return Object.entries(TERMINAL_ONLY_LAUNCH_OPTIONS).filter(([key]) => options[key] !== void 0 && options[key] !== false).map(([, name]) => name);
17
+ }
18
+ function parseTerminalLauncherOutput(output) {
19
+ const text = output.trim();
20
+ if (!text) return { status: "fail", error: "the terminal launcher printed no summary" };
21
+ try {
22
+ return JSON.parse(text);
23
+ } catch {
24
+ const last = text.split("\n").filter((line) => line.trim()).pop() ?? "";
25
+ try {
26
+ return JSON.parse(last);
27
+ } catch {
28
+ return { status: "fail", error: `the terminal launcher printed no JSON summary: ${last.slice(0, 200)}` };
29
+ }
30
+ }
31
+ }
32
+ async function launchTerminal({ options, target, jsonOutput, stream, usage }) {
33
+ const cdpPort = str(options, "cdpPort") ?? process.env.RECIPE_CDP_PORT ?? process.env.CDP_PORT;
34
+ const appPort = str(options, "watcherPort") ?? process.env.TERMINAL_APP_PORT ?? process.env.RECIPE_WATCHER_PORT ?? process.env.WATCHER_PORT;
35
+ if (!cdpPort || !appPort) {
36
+ return usage(
37
+ "terminal launch needs the slot browser CDP port and the slot dev-server port.",
38
+ "mm-harness launch --adapter terminal --cdp-port <port> --watcher-port <app port>"
39
+ );
40
+ }
41
+ const args = [
42
+ path.join(runnerDir, "adapters", "terminal", "launch.mjs"),
43
+ "--target",
44
+ target,
45
+ "--cdp-port",
46
+ cdpPort,
47
+ "--app-port",
48
+ appPort,
49
+ "--signer",
50
+ str(options, "signer") ?? process.env.TERMINAL_SIGNER ?? "extension",
51
+ "--account",
52
+ str(options, "account") ?? process.env.TERMINAL_ACCOUNT ?? "dev1",
53
+ "--json"
54
+ ];
55
+ if (flag(options, "freshProfile")) args.push("--fresh-profile");
56
+ if (flag(options, "headless")) args.push("--headless");
57
+ if (flag(options, "headful")) args.push("--headful");
58
+ const slowMo = str(options, "slowMo");
59
+ if (slowMo) args.push("--slow-mo", slowMo);
60
+ const network = str(options, "network");
61
+ if (network) args.push("--network", network);
62
+ const mainnetConfirmation = str(options, "mainnetConfirmation");
63
+ if (mainnetConfirmation) args.push("--mainnet-confirmation", mainnetConfirmation);
64
+ stream.phase("launch", { adapter: "terminal", cdpPort, appPort });
65
+ let output = "";
66
+ let status = 0;
67
+ try {
68
+ output = execFileSync(process.execPath, args, { encoding: "utf8", stdio: ["ignore", "pipe", "inherit"] });
69
+ } catch (error) {
70
+ status = 1;
71
+ output = String(error.stdout ?? "");
72
+ }
73
+ const result = parseTerminalLauncherOutput(output);
74
+ if (jsonOutput) console.log(JSON.stringify({ schemaVersion: 1, command: "launch", adapter: "terminal", target, ...result }, null, 2));
75
+ else if (result.status === "pass") console.log(`\u2713 terminal browser ${result.reused ? "reused" : "started"} on CDP ${result.cdpPort} (${result.signer}, ${result.account?.name})`);
76
+ else console.error(`\u2717 terminal launch failed: ${result.error ?? "unknown error"}`);
77
+ stream.complete(result.status === "pass" ? "pass" : "fail", status);
78
+ return result.status === "pass" ? 0 : 1;
79
+ }
80
+ export {
81
+ TERMINAL_ONLY_LAUNCH_OPTIONS,
82
+ launchTerminal,
83
+ parseTerminalLauncherOutput,
84
+ terminalOnlyLaunchFlags
85
+ };
@@ -3,6 +3,7 @@ import { shellQuote } from "../../commands/parse-args.js";
3
3
  import fs from "node:fs";
4
4
  import { TERMINAL_CONSOLE_FILES, terminalRuntimeFile } from "./console-capture.js";
5
5
  import { stopTerminalBrowserSync } from "./stop.js";
6
+ import { adapterRegistry } from "../registry.js";
6
7
  function portFromEnv(...names) {
7
8
  for (const name of names) {
8
9
  const raw = process.env[name];
@@ -95,8 +96,10 @@ const terminalSurface = {
95
96
  launch: "mm-harness launch --adapter terminal --cdp-port <port> --watcher-port <app port>",
96
97
  relaunch: "mm-harness launch --adapter terminal --fresh-profile",
97
98
  runtimeProbeRecovery: (target) => `mm-harness doctor --adapter terminal --target ${shellQuote(path.resolve(target))}`
98
- }
99
+ },
100
+ launch: async (context) => (await import("./launch.js")).launchTerminal(context)
99
101
  };
102
+ adapterRegistry.register(terminalSurface);
100
103
  export {
101
104
  terminalAppPort,
102
105
  terminalCdpPort,
@@ -0,0 +1,346 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import { color, colorHumanMessage } from "../../cli-color.js";
4
+ import { handleHarness } from "../../harness.js";
5
+ import { recipeRuntimePath, walletFixturePath } from "../../paths.js";
6
+ import { getAdapterSurface } from "../../adapters/surface.js";
7
+ import { EXIT, flag, str } from "../shared.js";
8
+ import {
9
+ checkHealBounds,
10
+ ensureOverlay,
11
+ newHealState
12
+ } from "../../heal-bounds.js";
13
+ import {
14
+ FeatureFlagSpecError,
15
+ fixtureFeatureFlags,
16
+ parseRemoteFlagSpec,
17
+ remoteFlagRequestFor
18
+ } from "../../feature-flags.js";
19
+ import { terminalOnlyLaunchFlags } from "../../adapters/terminal/launch.js";
20
+ async function runDeviceLaunch(context, profile) {
21
+ const { adapter, options, target, heal, jsonOutput, machine, stream, usage } = context;
22
+ const terminalOnly = terminalOnlyLaunchFlags(options);
23
+ if (terminalOnly.length > 0) {
24
+ return usage(
25
+ `${terminalOnly.join(", ")} apply only to --adapter terminal; ${adapter} does not read them.`,
26
+ "drop the terminal flags, or pass --adapter terminal"
27
+ );
28
+ }
29
+ const wantBuild = flag(options, "build");
30
+ const wantVerify = flag(options, "verify");
31
+ const wantWatch = flag(options, "watch");
32
+ const wantRunway = flag(options, "runway");
33
+ const wantClearMetro = flag(options, "clearMetro");
34
+ const requestedSurface = str(options, "surface");
35
+ const legacySidepanel = flag(options, "sidepanel");
36
+ const legacyFullscreen = flag(options, "fullscreen");
37
+ const remoteFlagSpec = str(options, "remoteFlag");
38
+ const refused = profile.refuseFlags(context, { watch: wantWatch, runway: wantRunway, clearMetro: wantClearMetro, remoteFlagSpec });
39
+ if (refused) return usage(refused.message, refused.userAction);
40
+ if (requestedSurface !== void 0 && requestedSurface !== "fullscreen" && requestedSurface !== "sidepanel") {
41
+ return usage(
42
+ `--surface must be fullscreen or sidepanel (got: ${requestedSurface}).`,
43
+ "pass --surface fullscreen or --surface sidepanel"
44
+ );
45
+ }
46
+ if (legacySidepanel && legacyFullscreen) {
47
+ return usage("--sidepanel and --fullscreen conflict.", "pass exactly one Extension surface");
48
+ }
49
+ const legacySurface = legacySidepanel ? "sidepanel" : legacyFullscreen ? "fullscreen" : void 0;
50
+ if (requestedSurface && legacySurface && requestedSurface !== legacySurface) {
51
+ return usage(
52
+ `--surface ${requestedSurface} conflicts with --${legacySurface}.`,
53
+ "pass one consistent Extension surface"
54
+ );
55
+ }
56
+ const displayMode = requestedSurface ?? legacySurface ?? "fullscreen";
57
+ let remoteFlagRequest;
58
+ try {
59
+ remoteFlagRequest = remoteFlagRequestFor({
60
+ ...fixtureFeatureFlags(walletFixturePath(target)),
61
+ ...remoteFlagSpec === void 0 ? {} : parseRemoteFlagSpec(remoteFlagSpec)
62
+ });
63
+ } catch (error) {
64
+ if (!(error instanceof FeatureFlagSpecError)) throw error;
65
+ return usage(error.message, error.userAction);
66
+ }
67
+ for (const portFlag of ["cdpPort", "watcherPort"]) {
68
+ const value = str(options, portFlag);
69
+ if (value !== void 0 && !/^\d+$/u.test(value)) {
70
+ return usage(
71
+ `--${portFlag === "cdpPort" ? "cdp-port" : "watcher-port"} must be numeric (got: ${value}).`,
72
+ `pass a numeric port, e.g. --${portFlag === "cdpPort" ? "cdp-port 6663" : "watcher-port 8081"}`
73
+ );
74
+ }
75
+ }
76
+ getAdapterSurface(adapter).resolveSlotPorts(target);
77
+ const envError = profile.applyEnv?.(context);
78
+ if (envError) return usage(envError.message, envError.userAction);
79
+ const cdpPort = str(options, "cdpPort");
80
+ if (cdpPort) {
81
+ process.env.CDP_PORT = cdpPort;
82
+ process.env.RECIPE_CDP_PORT = cdpPort;
83
+ }
84
+ const watcherPort = str(options, "watcherPort");
85
+ if (watcherPort) {
86
+ process.env.WATCHER_PORT = watcherPort;
87
+ process.env.RECIPE_WATCHER_PORT = watcherPort;
88
+ process.env.METRO_PORT = watcherPort;
89
+ }
90
+ const launch = {
91
+ ...context,
92
+ tier: wantBuild ? "build" : "quick",
93
+ wantWatch,
94
+ wantVerify,
95
+ wantClearMetro,
96
+ displayMode,
97
+ remoteFlagRequest,
98
+ compose: (tier2, restartApp = false, clearMetro = false) => profile.compose(launch, tier2, restartApp, clearMetro),
99
+ settle: (state2, provenFlags, sidepanelRecoveryAllowed = true) => settleLaunch(launch, profile, state2, sidepanelRecoveryAllowed, provenFlags),
100
+ fail: (state2, failure) => launchFail(launch, profile, state2, failure)
101
+ };
102
+ const { tier } = launch;
103
+ profile.start(launch);
104
+ const state = newHealState();
105
+ if (tier === "quick" && nativeInputsChanged(target, profile.nativeInputs)) {
106
+ return usage("native build inputs changed since last build.", profile.rebuildCommand(launch));
107
+ }
108
+ stream.phase("install");
109
+ const ensured = await ensureOverlay(adapter, target, heal, state, machine);
110
+ if (!ensured.ok) {
111
+ return launch.fail(state, {
112
+ code: "OVERLAY_INSTALL_FAILED",
113
+ message: ensured.error ?? "overlay install failed",
114
+ recoverable: false,
115
+ exitCode: EXIT.infra
116
+ });
117
+ }
118
+ stream.phase("healthcheck");
119
+ const blocked = await profile.healthcheck?.(launch, state);
120
+ if (blocked !== void 0) return blocked;
121
+ stream.phase("launch");
122
+ let attempt = await launch.compose(tier, false, wantClearMetro);
123
+ if (attempt.status === 0) {
124
+ return await launch.settle(state, attempt.remoteFeatureFlags);
125
+ }
126
+ const readinessFailure = readinessBlockFailure(attempt);
127
+ if (readinessFailure) return launch.fail(state, readinessFailure);
128
+ const classified = profile.classify(launch, attempt.output);
129
+ if (classified) return launch.fail(state, classified);
130
+ if (heal === "off") {
131
+ return launch.fail(state, {
132
+ code: "LAUNCH_FAILED",
133
+ message: `launch ${adapter}${launch.mobileTarget ? ` ${launch.mobileTarget}` : ""} failed (healing off)`,
134
+ recoverable: false,
135
+ exitCode: EXIT.infra
136
+ });
137
+ }
138
+ const handled = await profile.beforeHeal?.(launch, attempt, state);
139
+ if (handled !== void 0) return handled;
140
+ const bound = checkHealBounds(target, attempt.output, state);
141
+ if (bound !== null) {
142
+ return launch.fail(state, {
143
+ code: bound.code,
144
+ message: bound.message,
145
+ recoverable: false,
146
+ userAction: bound.userAction,
147
+ exitCode: bound.exitCode,
148
+ originalError: bound.originalError
149
+ });
150
+ }
151
+ const recovery = profile.recovery(launch, attempt.output);
152
+ state.attemptedRecoveries.push(recovery.code);
153
+ stream.phase("recover");
154
+ const recoveryTier = recovery.restartApp && tier === "build" ? "quick" : tier;
155
+ if (!jsonOutput) {
156
+ process.stderr.write(`launch: recovering (${recovery.code}) with the ${recoveryTier} tier
157
+ `);
158
+ }
159
+ attempt = await launch.compose(recoveryTier, recovery.restartApp, false);
160
+ if (attempt.status === 0) {
161
+ state.recovered.push(recovery.code);
162
+ return await launch.settle(state, attempt.remoteFeatureFlags);
163
+ }
164
+ const retryReadinessFailure = readinessBlockFailure(attempt);
165
+ if (retryReadinessFailure) return launch.fail(state, retryReadinessFailure);
166
+ return launch.fail(state, {
167
+ code: "SAME_RECOVERY_TWICE",
168
+ message: `launch ${adapter}${launch.mobileTarget ? ` ${launch.mobileTarget}` : ""} failed again after ${recovery.code} recovery \u2014 refusing to loop.`,
169
+ recoverable: false,
170
+ exitCode: EXIT.bounded,
171
+ originalError: attempt.output.trim() || void 0
172
+ });
173
+ }
174
+ function readinessBlockFailure(result) {
175
+ const block = result.readinessBlock;
176
+ if (!block) return void 0;
177
+ return {
178
+ code: block.reasonCode === "deps-not-ready" ? "MOBILE_DEPENDENCIES_NOT_READY" : "MOBILE_READINESS_BLOCKED",
179
+ message: block.message,
180
+ userAction: block.userAction,
181
+ recoverable: false,
182
+ exitCode: result.status
183
+ };
184
+ }
185
+ function shellQuote(value) {
186
+ if (/^[A-Za-z0-9_./:@%+=,-]+$/u.test(value)) return value;
187
+ return `'${value.replace(/'/gu, `'"'"'`)}'`;
188
+ }
189
+ function nativeInputsChanged(target, paths) {
190
+ const baselineFile = recipeRuntimePath(target, ".last-build-ref");
191
+ let baseline;
192
+ try {
193
+ baseline = fs.readFileSync(baselineFile, "utf8").trim();
194
+ } catch {
195
+ return false;
196
+ }
197
+ if (!baseline) return false;
198
+ try {
199
+ const diff = execFileSync("git", ["-C", target, "diff", "--name-only", baseline, "--", ...paths], {
200
+ encoding: "utf8",
201
+ stdio: ["ignore", "pipe", "ignore"]
202
+ }).trim();
203
+ return diff.length > 0;
204
+ } catch {
205
+ return false;
206
+ }
207
+ }
208
+ async function settleLaunch(launch, profile, state, sidepanelRecoveryAllowed, provenFlags) {
209
+ const proven = profile.provenFlags(launch, launch.remoteFlagRequest?.flags ?? {}, provenFlags);
210
+ if (!("flags" in proven)) return launch.fail(state, proven);
211
+ const stopped = await profile.finish?.(launch, state, sidepanelRecoveryAllowed);
212
+ if (stopped !== void 0) return stopped;
213
+ const { adapter, target, stream, tier } = launch;
214
+ if (launch.wantVerify) {
215
+ stream.phase("verify");
216
+ const verifyArgs = ["verify", "--adapter", adapter, "--target", target, ...profile.verifyArgs(launch)];
217
+ if (launch.machine) verifyArgs.push("--json");
218
+ const exitCode = await handleHarness(verifyArgs);
219
+ if (stream.enabled) {
220
+ for (const mutation of state.mutations) stream.mutation(mutation);
221
+ for (const recovery of state.recovered) stream.recovery(recovery);
222
+ stream.complete(exitCode === EXIT.ok ? "pass" : "fail", exitCode, {
223
+ adapter,
224
+ platform: profile.platformLabel(launch),
225
+ tier,
226
+ recovered: state.recovered,
227
+ mutations: state.mutations,
228
+ metro: profile.evidence?.(target),
229
+ remoteFeatureFlags: proven.flags
230
+ });
231
+ }
232
+ return exitCode;
233
+ }
234
+ return launchPass(launch, profile, state, proven.flags);
235
+ }
236
+ function launchPass(launch, profile, state, remoteFlags) {
237
+ const { adapter, mobileTarget, target, stream, tier } = launch;
238
+ if (stream.enabled) {
239
+ for (const mutation of state.mutations) stream.mutation(mutation);
240
+ for (const recovery of state.recovered) stream.recovery(recovery);
241
+ stream.complete("pass", EXIT.ok, {
242
+ adapter,
243
+ platform: profile.platformLabel(launch),
244
+ tier,
245
+ recovered: state.recovered,
246
+ mutations: state.mutations,
247
+ metro: profile.evidence?.(target),
248
+ remoteFeatureFlags: remoteFlags
249
+ });
250
+ } else if (launch.jsonOutput) {
251
+ console.log(
252
+ JSON.stringify(
253
+ {
254
+ schemaVersion: 1,
255
+ command: "launch",
256
+ adapter,
257
+ platform: profile.platformLabel(launch),
258
+ tier,
259
+ status: "pass",
260
+ phase: "launch",
261
+ recovered: state.recovered,
262
+ mutations: state.mutations,
263
+ metro: profile.evidence?.(target),
264
+ remoteFeatureFlags: remoteFlags,
265
+ exitCode: EXIT.ok
266
+ },
267
+ null,
268
+ 2
269
+ )
270
+ );
271
+ } else {
272
+ const tierNote = tier === "quick" ? "quick relaunch, no native build" : tier;
273
+ const devNote = process.env.MM_HARNESS_BIN ? ` ${color("dim", "[dev: MM_HARNESS_BIN]")}` : "";
274
+ console.error(
275
+ `${color("ok", "\u2713")} launch ${adapter}${mobileTarget ? ` ${mobileTarget}` : ""} \u2014 ${color("ok", profile.deviceLabel(launch))} \xB7 ${tierNote} \xB7 app + bridge ready${devNote}`
276
+ );
277
+ }
278
+ return EXIT.ok;
279
+ }
280
+ function launchFail(launch, profile, state, failure) {
281
+ const { adapter, mobileTarget, target, stream, tier } = launch;
282
+ const userAction = failure.userAction ?? `mm-harness doctor --fix --adapter ${adapter} --target ${shellQuote(target)} --json`;
283
+ if (stream.enabled) {
284
+ for (const mutation of state.mutations) stream.mutation(mutation);
285
+ for (const recovery of state.recovered) stream.recovery(recovery);
286
+ stream.error({
287
+ code: failure.code,
288
+ message: failure.message,
289
+ retryable: failure.recoverable,
290
+ userAction,
291
+ originalError: failure.originalError ?? null
292
+ });
293
+ stream.complete("fail", failure.exitCode, {
294
+ adapter,
295
+ platform: mobileTarget ?? null,
296
+ tier,
297
+ recovered: state.recovered,
298
+ mutations: state.mutations,
299
+ recoverable: failure.recoverable,
300
+ attemptedRecoveries: state.attemptedRecoveries,
301
+ metro: profile.evidence?.(target)
302
+ });
303
+ } else if (launch.jsonOutput) {
304
+ console.log(
305
+ JSON.stringify(
306
+ {
307
+ schemaVersion: 1,
308
+ command: "launch",
309
+ adapter,
310
+ platform: mobileTarget ?? null,
311
+ tier,
312
+ status: "fail",
313
+ phase: state.attemptedRecoveries.length ? "recover" : "launch",
314
+ recovered: state.recovered,
315
+ mutations: state.mutations,
316
+ recoverable: failure.recoverable,
317
+ attemptedRecoveries: state.attemptedRecoveries,
318
+ metro: profile.evidence?.(target),
319
+ exitCode: failure.exitCode,
320
+ error: {
321
+ code: failure.code,
322
+ message: failure.message,
323
+ retryable: failure.recoverable,
324
+ userAction,
325
+ originalError: failure.originalError ?? null
326
+ }
327
+ },
328
+ null,
329
+ 2
330
+ )
331
+ );
332
+ } else {
333
+ console.error(colorHumanMessage(
334
+ `\u2717 launch ${adapter}${mobileTarget ? ` ${mobileTarget}` : ""} failed
335
+ ${failure.message}` + (failure.originalError ? `
336
+ --- original failure ---
337
+ ${failure.originalError}` : "") + `
338
+ Next: ${userAction}`
339
+ ));
340
+ }
341
+ return failure.exitCode;
342
+ }
343
+ export {
344
+ runDeviceLaunch,
345
+ shellQuote
346
+ };