@deeeed/metamask-harness 0.75.0 → 0.77.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/adapters/shared/reap-checkout-metros.sh +9 -0
  3. package/dist/adapters/core/overlay.js +34 -0
  4. package/dist/adapters/core/readiness.js +8 -0
  5. package/dist/adapters/core/surface.js +7 -0
  6. package/dist/adapters/extension/doctor-checks.js +135 -0
  7. package/dist/adapters/extension/fixtures.js +103 -0
  8. package/dist/adapters/extension/overlay.js +55 -0
  9. package/dist/adapters/extension/readiness.js +32 -0
  10. package/dist/adapters/extension/reload.js +40 -0
  11. package/dist/adapters/extension/surface.js +22 -1
  12. package/dist/adapters/harness-freshness.js +4 -2
  13. package/dist/adapters/mobile/devices.js +21 -0
  14. package/dist/adapters/mobile/doctor-checks.js +40 -0
  15. package/dist/adapters/mobile/fixtures.js +141 -0
  16. package/dist/adapters/mobile/overlay.js +27 -0
  17. package/dist/adapters/mobile/prepare-target.js +38 -0
  18. package/dist/adapters/mobile/readiness.js +39 -0
  19. package/dist/adapters/mobile/reload.js +15 -0
  20. package/dist/adapters/mobile/surface.js +28 -1
  21. package/dist/adapters/terminal/doctor-checks.js +22 -0
  22. package/dist/adapters/terminal/overlay.js +18 -0
  23. package/dist/adapters/terminal/readiness.js +11 -0
  24. package/dist/adapters/terminal/surface.js +15 -2
  25. package/dist/cli.js +3 -1
  26. package/dist/commands/call.js +3 -7
  27. package/dist/commands/debug.js +6 -25
  28. package/dist/commands/device-target.js +2 -1
  29. package/dist/commands/doctor.js +26 -137
  30. package/dist/commands/fixtures.js +13 -217
  31. package/dist/commands/list-executables.js +12 -22
  32. package/dist/commands/logs.js +1 -1
  33. package/dist/commands/manifest.js +11 -133
  34. package/dist/commands/parse-args.js +9 -4
  35. package/dist/commands/prepare.js +23 -53
  36. package/dist/commands/provision.js +3 -7
  37. package/dist/commands/reload.js +8 -47
  38. package/dist/commands/run.js +2 -5
  39. package/dist/commands/runtime-decision.js +5 -27
  40. package/dist/commands/self-test.js +1 -3
  41. package/dist/commands/status.js +20 -28
  42. package/dist/commands/stop.js +1 -1
  43. package/dist/doctor.js +9 -119
  44. package/dist/harness.js +31 -100
  45. package/dist/mm-harness-cli.js +3 -2
  46. package/dist/paths.js +8 -2
  47. package/dist/runtime-context.js +12 -17
  48. package/docs/CONTRIBUTING.md +27 -17
  49. package/package.json +2 -2
@@ -1,6 +1,5 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { readExtensionPinnedFlags } from "../adapters/extension/feature-flags.js";
4
3
  import { featureFlagReport } from "../feature-flags.js";
5
4
  import { color } from "../cli-color.js";
6
5
  import { collectCheckoutView, renderCheckoutView } from "../checkout-view.js";
@@ -10,15 +9,6 @@ import { getAdapterSurface } from "../adapters/surface.js";
10
9
  import {
11
10
  applyDeviceTargeting
12
11
  } from "./device-target.js";
13
- import {
14
- mobileDeviceView,
15
- mobileDeviceLiveView,
16
- nextForLive,
17
- renderAndroidPortHints,
18
- renderMobileDeviceList,
19
- renderAdditionalReachableDevices,
20
- renderMobileLiveBlock
21
- } from "./mobile-device-view.js";
22
12
  import { ADAPTER_DETECT_NEXT, EXIT, usageOut } from "./shared.js";
23
13
  import { handleStatusTaskView, renderAcceptanceLines } from "./status-watch.js";
24
14
  import { collectTaskView, findTaskDir } from "../task-view.js";
@@ -45,23 +35,25 @@ async function handleStatus({ options }) {
45
35
  const view = collectCheckoutView(target, adapter);
46
36
  const surface = getAdapterSurface(adapter);
47
37
  surface.resolveSlotPorts(target);
48
- if (adapter === "mobile") {
38
+ const { readiness } = surface;
39
+ const deviceSource = await readiness.devices?.();
40
+ if (deviceSource) {
49
41
  const dtResult = applyDeviceTargeting("status", adapter, options, { gate: false, rerun: "" });
50
42
  if ("code" in dtResult) {
51
43
  return usageOut(json, "status", dtResult.message, dtResult.userAction);
52
44
  }
53
45
  }
54
46
  const next = surface.hints.relaunch;
55
- const extensionFlags = adapter === "extension" ? readExtensionPinnedFlags(target) : null;
56
- const staticFeatureFlags = extensionFlags ? featureFlagReport(extensionFlags.overrides, extensionFlags) : featureFlagReport(null);
57
- const deviceView = adapter === "mobile" ? mobileDeviceView(allDevices) : null;
47
+ const pinnedFlags = readiness.pinnedFlags?.(target) ?? null;
48
+ const staticFeatureFlags = pinnedFlags ? featureFlagReport(pinnedFlags.overrides, pinnedFlags) : featureFlagReport(null);
49
+ const deviceView = deviceSource ? deviceSource.view(allDevices) : null;
58
50
  const devices = deviceView?.devices ?? [];
59
51
  const discovery = deviceView?.deviceDiscoveryErrors.length ? { deviceDiscoveryErrors: deviceView.deviceDiscoveryErrors } : {};
60
52
  const discoveryNext = discovery.deviceDiscoveryErrors?.[0]?.userAction;
61
53
  if (json) {
62
- const doProbe = !fast && adapter === "mobile" && deviceView !== null && deviceView.allConnectedDevices.length > 0;
54
+ const doProbe = !fast && deviceSource && deviceView !== null && deviceView.allConnectedDevices.length > 0;
63
55
  if (doProbe) {
64
- const liveView = await mobileDeviceLiveView(target, deviceView);
56
+ const liveView = await deviceSource.live(target, deviceView);
65
57
  console.log(
66
58
  JSON.stringify(
67
59
  {
@@ -75,14 +67,14 @@ async function handleStatus({ options }) {
75
67
  ...discovery,
76
68
  ...liveView.additionalReachableDevices.length > 0 ? { additionalReachableDevices: liveView.additionalReachableDevices } : {},
77
69
  ...liveView.androidPortHints.length > 0 ? { androidPortHints: liveView.androidPortHints } : {},
78
- next: discoveryNext ?? nextForLive(next, liveView.liveMap)
70
+ next: discoveryNext ?? deviceSource.nextForLive(next, liveView.liveMap)
79
71
  },
80
72
  null,
81
73
  2
82
74
  )
83
75
  );
84
76
  } else {
85
- const runtime = !fast && adapter === "extension" ? await surface.runtimeStatus(target) : void 0;
77
+ const runtime = !fast && readiness.statusRuntime ? await surface.runtimeStatus(target) : void 0;
86
78
  console.log(
87
79
  JSON.stringify(
88
80
  {
@@ -113,21 +105,21 @@ async function handleStatus({ options }) {
113
105
  console.log(renderAcceptanceLines(taskView).join("\n"));
114
106
  }
115
107
  }
116
- if (adapter !== "mobile") renderFeatureFlagLine(staticFeatureFlags, out);
117
- if (deviceView) renderMobileDeviceList(deviceView, out);
118
- if (!fast && adapter === "mobile" && deviceView !== null && deviceView.allConnectedDevices.length > 0) {
119
- const liveView = await mobileDeviceLiveView(target, deviceView);
120
- if (liveView.additionalReachableDevices.length > 0) renderAdditionalReachableDevices(liveView.additionalReachableDevices, out);
108
+ if (!deviceSource) renderFeatureFlagLine(staticFeatureFlags, out);
109
+ if (deviceSource && deviceView) deviceSource.renderList(deviceView, out);
110
+ if (!fast && deviceSource && deviceView !== null && deviceView.allConnectedDevices.length > 0) {
111
+ const liveView = await deviceSource.live(target, deviceView);
112
+ if (liveView.additionalReachableDevices.length > 0) deviceSource.renderAdditional(liveView.additionalReachableDevices, out);
121
113
  renderFeatureFlagLine(featureFlagReport(liveView.featureFlagOverrides), out);
122
- renderMobileLiveBlock(devices, liveView.liveMap, out);
123
- renderAndroidPortHints(liveView.androidPortHints, out);
124
- console.log(`${out("label", "Next:")} ${out("cmd", discoveryNext ?? nextForLive(next, liveView.liveMap))}`);
125
- } else if (!fast && adapter === "extension") {
114
+ deviceSource.renderLive(devices, liveView.liveMap, out);
115
+ deviceSource.renderPortHints(liveView.androidPortHints, out);
116
+ console.log(`${out("label", "Next:")} ${out("cmd", discoveryNext ?? deviceSource.nextForLive(next, liveView.liveMap))}`);
117
+ } else if (!fast && readiness.statusRuntime) {
126
118
  const runtime = await surface.runtimeStatus(target);
127
119
  renderRuntimeStatus(runtime, out);
128
120
  console.log(`${out("label", "Next:")} ${out("cmd", nextForRuntime(surface.hints.launch, surface.hints.relaunch, runtime))}`);
129
121
  } else {
130
- if (adapter === "mobile") renderFeatureFlagLine(featureFlagReport(null), out);
122
+ if (deviceSource) renderFeatureFlagLine(featureFlagReport(null), out);
131
123
  console.log(`${out("label", "Next:")} ${out("cmd", discoveryNext ?? next)}`);
132
124
  }
133
125
  return EXIT.ok;
@@ -27,7 +27,7 @@ async function handleStop(argv) {
27
27
  const collector = await stopProjectCollector(target);
28
28
  const signalled = (stop.signalled ?? 0) + (collector.stopped ? 1 : 0);
29
29
  const summary = collector.stopped ? `${stop.summary}; stopped analytics collector ${collector.pid}` : stop.summary;
30
- const next = adapter === "extension" ? `${surface.hints.launch} --target ${shellQuote(target)}` : void 0;
30
+ const next = surface.devServer.afterStop?.(target);
31
31
  const userAction = `mm-harness status --target ${shellQuote(target)} --json`;
32
32
  if (json) {
33
33
  console.log(
package/dist/doctor.js CHANGED
@@ -1,17 +1,11 @@
1
- import { spawnSync } from "node:child_process";
2
1
  import fs from "node:fs";
3
- import { createRequire } from "node:module";
4
2
  import path from "node:path";
5
3
  import { color } from "./cli-color.js";
6
4
  import { normalizeFeatureFlagRecord } from "./feature-flags.js";
7
- import {
8
- mobileToolRecovery,
9
- resolveMobileToolPath
10
- } from "../library/actions/mobile/platform/tool-paths.mjs";
11
- import { mobilePerpsEnvironment } from "./adapters/mobile/perps-env.js";
5
+ import { getAdapterSurface } from "./adapters/surface.js";
12
6
  import { readRuntimeContextField, resolveRuntimeContextPath } from "./harness.js";
13
7
  import { isPin } from "./harness-pin.js";
14
- import { harnessExecutable, manifestPath, readJson, recipeHarnessRoot, recipeRuntimeDir, runnerDir } from "./paths.js";
8
+ import { harnessExecutable, readJson, recipeHarnessRoot, recipeRuntimeDir, runnerDir } from "./paths.js";
15
9
  function requiredDoctorCheckSummary(checks) {
16
10
  const required = checks.filter((check) => check.required);
17
11
  const failed = required.filter((check) => check.status === "fail").map((check) => check.id);
@@ -42,21 +36,7 @@ function repoShape(target) {
42
36
  };
43
37
  }
44
38
  function compatibilityMode(adapter, target) {
45
- const shape = repoShape(target);
46
- if (adapter === "core") {
47
- return fs.existsSync(path.join(target, "packages/perps-controller/src/index.ts")) ? "headless controller (no bridge)" : "unsupported/no bridge";
48
- }
49
- if (adapter === "terminal") {
50
- return fs.existsSync(path.join(target, "src/features/perpetuals")) ? "slot browser over CDP (website; no product bridge)" : "unsupported/no bridge";
51
- }
52
- if (adapter === "mobile") {
53
- if (shape.injectedHarness && shape.agenticService) return "runner bridge with injected app bridge";
54
- if (shape.agenticService) return "runner bridge with app bridge";
55
- return "runner bridge available; app bridge not installed";
56
- }
57
- if (!shape.extensionProject) return "unsupported/no bridge";
58
- if (shape.extensionRuntime || shape.injectedHarness) return "bridge present";
59
- return "bridge injectable";
39
+ return getAdapterSurface(adapter).readiness.compatibilityMode(target, repoShape(target));
60
40
  }
61
41
  function readPackageInfo(target) {
62
42
  const packageJsonPath = path.join(target, "package.json");
@@ -97,7 +77,8 @@ function fixtureFileSummary(file, adapter, reportedPath = file) {
97
77
  );
98
78
  const flagKeys = Object.keys(remoteFeatureFlags);
99
79
  return {
100
- status: hasAccounts && (adapter === "core" || hasPassword) ? "ready" : "incomplete",
80
+ // A headless platform signs with the accounts alone; nothing unlocks a UI.
81
+ status: hasAccounts && (adapter !== void 0 && getAdapterSurface(adapter).headless || hasPassword) ? "ready" : "incomplete",
101
82
  path: reportedPath,
102
83
  accountCount: Array.isArray(data.accounts) ? data.accounts.length : 0,
103
84
  hasPassword,
@@ -155,7 +136,8 @@ function renderRuntimeContext(runtimeContext) {
155
136
  }
156
137
  return lines.join("\n");
157
138
  }
158
- function createDoctorReport(adapter, target, manifestValidation, actionManifestPath = manifestPath(adapter), mobilePlatform) {
139
+ function createDoctorReport(adapter, target, manifestValidation, actionManifestPath = getAdapterSurface(adapter).actions.manifestPath(), platform) {
140
+ const readiness = getAdapterSurface(adapter).readiness;
159
141
  const mode = compatibilityMode(adapter, target);
160
142
  const manifestErrors = Number(manifestValidation.summary?.errors ?? 0);
161
143
  const checks = [
@@ -171,8 +153,7 @@ function createDoctorReport(adapter, target, manifestValidation, actionManifestP
171
153
  required: false,
172
154
  message: mode === "unsupported/no bridge" ? `No ${adapter} bridge is available for this checkout.` : `${adapter} compatibility mode: ${mode}.`
173
155
  },
174
- ...adapter === "mobile" ? mobileToolDoctorChecks(mobilePlatform) : [],
175
- ...adapter === "extension" ? [extensionBrowserDoctorCheck(target), extensionBrowserResolutionDoctorCheck(target)] : []
156
+ ...readiness.checks?.(target, platform) ?? []
176
157
  ];
177
158
  const requiredChecks = requiredDoctorCheckSummary(checks);
178
159
  return {
@@ -188,99 +169,11 @@ function createDoctorReport(adapter, target, manifestValidation, actionManifestP
188
169
  compatibilityMode: mode,
189
170
  shape: repoShape(target),
190
171
  fixture: fixtureSummary(target, adapter),
191
- environment: adapter === "mobile" ? { mobilePerps: mobilePerpsEnvironment(target) } : {},
172
+ environment: readiness.environment?.(target) ?? {},
192
173
  runtimeContext: runtimeContextSummary(target, adapter),
193
174
  manifestValidation: manifestValidation.summary
194
175
  };
195
176
  }
196
- function extensionBrowserDoctorCheck(target) {
197
- const requireFromTarget = createRequire(path.join(path.resolve(target), "package.json"));
198
- for (const packageName of ["@playwright/test", "playwright"]) {
199
- try {
200
- const playwright = requireFromTarget(packageName);
201
- const executable = playwright.chromium?.executablePath?.();
202
- if (executable && fs.existsSync(executable)) {
203
- return {
204
- id: "playwright-chromium",
205
- status: "pass",
206
- required: true,
207
- message: "Checkout-pinned Playwright Chromium is installed.",
208
- detail: executable
209
- };
210
- }
211
- } catch {
212
- }
213
- }
214
- return {
215
- id: "playwright-chromium",
216
- status: "fail",
217
- required: true,
218
- message: "Checkout-pinned Playwright Chromium is not installed.",
219
- userAction: "yarn playwright install chromium"
220
- };
221
- }
222
- function extensionBrowserResolutionDoctorCheck(target) {
223
- const resolver = path.join(runnerDir, "adapters/shared/browser-resolver.cjs");
224
- const runtime = path.join(path.resolve(target), recipeRuntimeDir());
225
- const record = path.join(runtime, "browser-resolution.json");
226
- let profile = process.env.CHROME_USER_DATA_DIR || path.join(runtime, process.env.RECIPE_CHROME_PROFILE_NAME || "chrome-profile");
227
- while (profile.length > 1 && profile.endsWith("/")) profile = profile.slice(0, -1);
228
- const profileExists = fs.existsSync(path.join(profile, "Local State"));
229
- const result = spawnSync(process.execPath, [
230
- resolver,
231
- "resolve",
232
- "--target",
233
- path.resolve(target),
234
- "--launch-method",
235
- "launch-services",
236
- "--display",
237
- "headful",
238
- ...profileExists && fs.existsSync(record) ? ["--recorded", record] : [],
239
- "--json"
240
- ], {
241
- cwd: path.resolve(target),
242
- encoding: "utf8",
243
- timeout: 135e3
244
- });
245
- if (result.status === 0) {
246
- const resolution = JSON.parse(result.stdout);
247
- const managed = resolution.managedChrome;
248
- const managedNote = managed && !resolution.warning?.includes("managed by policy") ? ` Google Chrome is managed by policy (${managed.forcedExtensions.length} forced extension${managed.forcedExtensions.length === 1 ? "" : "s"}: ${managed.forcedExtensions.map(({ id, name }) => name ? `${id} (${name})` : id).join(", ")}${managed.signinForced ? "; browser sign-in forced" : ""}); slots use Chrome for Testing.` : "";
249
- const probe = resolution.probe ? `; Chrome for Testing probe (${resolution.probe.launchMethod}, ${resolution.probe.display}${resolution.probe.cached ? ", cached" : ""}${resolution.probe.transient ? `, retried after ${resolution.probe.expiresAt}` : ""}): ${resolution.probe.reason}` : resolution.recordedFrom ? `; kept from the slot profile record ${resolution.recordedFrom}` : "";
250
- return {
251
- id: "extension-browser",
252
- status: "pass",
253
- required: false,
254
- message: `Launch browser: ${resolution.browser} ${resolution.version ?? "(unknown version)"} via ${resolution.source} (${resolution.mode}); extension loading: ${resolution.extensionLoading}${probe}.${managedNote}${resolution.warning ? ` Warning: ${resolution.warning}` : ""}`,
255
- detail: resolution.bin
256
- };
257
- }
258
- const message = (result.stderr || result.error?.message || "browser resolution failed").trim();
259
- return {
260
- id: "extension-browser",
261
- status: "fail",
262
- required: message.includes("BROWSER_UNLAUNCHABLE:"),
263
- message,
264
- userAction: "install Google Chrome, or set RECIPE_HARNESS_BROWSER / RECIPE_HARNESS_CHROME_BIN to a browser that starts"
265
- };
266
- }
267
- function mobileToolDoctorChecks(platform) {
268
- const specs = platform === "android" ? [{ tool: "adb", platform: "Android" }] : platform === "ios" ? [{ tool: "idb", platform: "iOS" }] : [
269
- { tool: "adb", platform: "Android" },
270
- { tool: "idb", platform: "iOS" }
271
- ];
272
- return specs.map(({ tool, platform: platformName }) => {
273
- const resolved = resolveMobileToolPath(tool);
274
- return {
275
- id: `device-tool-${tool}`,
276
- status: resolved ? "pass" : "fail",
277
- required: platform !== void 0,
278
- message: resolved ? `${platformName} device tool ${tool} is ready.` : `${platformName} device tool ${tool} is unavailable.`,
279
- ...resolved ? { detail: resolved } : {},
280
- ...!resolved ? { userAction: mobileToolRecovery(tool) } : {}
281
- };
282
- });
283
- }
284
177
  function runnerInstallKind(runnerRoot, invokedPath, executablePath) {
285
178
  if (isPin(runnerRoot)) return "pinned-install";
286
179
  const normalizedRoot = path.normalize(runnerRoot);
@@ -344,11 +237,8 @@ function readJsonObject(file) {
344
237
  export {
345
238
  compatibilityMode,
346
239
  createDoctorReport,
347
- extensionBrowserDoctorCheck,
348
- extensionBrowserResolutionDoctorCheck,
349
240
  fixtureFileSummary,
350
241
  fixtureSummary,
351
- mobileToolDoctorChecks,
352
242
  renderRuntimeContext,
353
243
  repoShape,
354
244
  requiredDoctorCheckSummary,
package/dist/harness.js CHANGED
@@ -3,7 +3,7 @@ import { execFileSync } from "node:child_process";
3
3
  import fs from "node:fs";
4
4
  import path from "node:path";
5
5
  import { shellLeafMissing, missingShellLeafMessage } from "./leaf-invoke.js";
6
- import { resolveExtensionSlotPorts } from "./adapters/slot-ports.js";
6
+ import { adapterRegistry } from "./adapters/registry.js";
7
7
  import { recipeHarnessPath, recipeRuntimeDir, runnerDir } from "./paths.js";
8
8
  import { colorHumanMessage } from "./cli-color.js";
9
9
  const HARNESS_ACTIONS = ["install", "verify", "cleanup"];
@@ -37,15 +37,15 @@ Examples:
37
37
 
38
38
  core supports install, verify, and cleanup (headless; no live).`);
39
39
  }
40
- function failureHint(adapter, action) {
41
- if (adapter === "core") {
40
+ function failureHint(surface, action) {
41
+ const restart = surface.harness.restart;
42
+ if (!restart) {
42
43
  if (action === "verify") {
43
- return `Install the core harness first: mm-harness install --platform core`;
44
+ return `Install the ${surface.adapter} harness first: mm-harness install --platform ${surface.adapter}`;
44
45
  }
45
46
  return "Read the error above for the specific cause, then re-run this command.";
46
47
  }
47
- const helper = adapter === "mobile" ? "mm-harness launch ios # or launch android" : "mm-harness launch";
48
- return `Read the error above for the specific cause. To (re)start the runtime, run: ${helper}`;
48
+ return `Read the error above for the specific cause. To (re)start the runtime, run: ${restart}`;
49
49
  }
50
50
  function successNext(adapter, action, target) {
51
51
  const base = `--adapter ${adapter} --target ${shellQuote(target)}`;
@@ -67,7 +67,7 @@ function shellQuote(value) {
67
67
  return /^[A-Za-z0-9_./:=@+-]+$/u.test(value) ? value : `'${value.replace(/'/gu, `'\\''`)}'`;
68
68
  }
69
69
  function isAdapter(value) {
70
- return value === "mobile" || value === "extension" || value === "core" || value === "terminal";
70
+ return value !== void 0 && adapterRegistry.has(value);
71
71
  }
72
72
  function parseHarnessArgs(args) {
73
73
  const forward = [];
@@ -170,37 +170,9 @@ function readRuntimeContextField(contextPath, field) {
170
170
  if (node === void 0 || node === null || node === "") return void 0;
171
171
  return String(node);
172
172
  }
173
- function applyExtensionRuntimeEnv(target, args) {
174
- const contextPath = resolveRuntimeContextPath(target);
175
- const contextExists = fs.existsSync(contextPath);
176
- if (contextExists) {
177
- process.env.RECIPE_RUNTIME_CONTEXT = contextPath;
178
- if (!process.env.RECIPE_SLOT_ID) {
179
- const slotId = readRuntimeContextField(contextPath, "slotId");
180
- if (slotId) process.env.RECIPE_SLOT_ID = slotId;
181
- }
182
- if (!process.env.RECIPE_HARNESS_EXTENSION_ID) {
183
- const extensionId = readRuntimeContextField(contextPath, "extensionId");
184
- if (extensionId) process.env.RECIPE_HARNESS_EXTENSION_ID = extensionId;
185
- }
186
- } else {
187
- delete process.env.RECIPE_RUNTIME_CONTEXT;
188
- }
189
- resolveExtensionSlotPorts(target);
190
- let result = [...args];
191
- if (!hasArg(result, "--cdp-port")) {
192
- const cdpPort = process.env.RECIPE_CDP_PORT ?? process.env.CDP_PORT;
193
- if (cdpPort) {
194
- process.env.RECIPE_CDP_PORT = cdpPort;
195
- process.env.CDP_PORT = cdpPort;
196
- result = [...result, "--cdp-port", cdpPort];
197
- }
198
- }
199
- return result;
200
- }
201
173
  function resolveEntry(base, candidates, mode) {
202
174
  for (const candidate of candidates) {
203
- const full = path.join(base, candidate);
175
+ const full = path.resolve(base, candidate);
204
176
  try {
205
177
  const stat = fs.statSync(full);
206
178
  if (!stat.isFile()) continue;
@@ -208,7 +180,7 @@ function resolveEntry(base, candidates, mode) {
208
180
  } catch {
209
181
  }
210
182
  }
211
- return path.join(base, candidates[0]);
183
+ return path.resolve(base, candidates[0]);
212
184
  }
213
185
  function installedRunnerSource(target, adapter) {
214
186
  const pointer = path.join(recipeHarnessPath(target, adapter), "runner", ".runner-source");
@@ -217,56 +189,12 @@ function installedRunnerSource(target, adapter) {
217
189
  if (!value || !fs.existsSync(value)) return void 0;
218
190
  return value;
219
191
  }
220
- function isExecutable(file) {
221
- try {
222
- const stat = fs.statSync(file);
223
- return stat.isFile() && (stat.mode & 73) !== 0;
224
- } catch {
225
- return false;
226
- }
227
- }
228
- const INJECT_CANDIDATES = {
229
- mobile: { entry: "adapters/mobile/inject.sh", fallback: "scripts/inject-mobile-harness.sh" },
230
- extension: { entry: "adapters/extension/inject.mjs", fallback: "scripts/inject-extension-harness.mjs" },
231
- core: { entry: "adapters/core/inject.sh", fallback: "scripts/inject-core-harness.sh" },
232
- terminal: { entry: "adapters/terminal/inject.mjs", fallback: "adapters/terminal/inject.mjs" }
233
- };
234
- const CLEANUP_CANDIDATES = {
235
- mobile: { entry: "adapters/mobile/cleanup.sh", fallback: "scripts/cleanup-mobile-harness.sh" },
236
- extension: { entry: "adapters/extension/cleanup.mjs", fallback: "scripts/cleanup-extension-harness.mjs" },
237
- core: { entry: "adapters/core/cleanup.sh", fallback: "scripts/cleanup-core-harness.sh" },
238
- terminal: { entry: "adapters/terminal/cleanup.mjs", fallback: "adapters/terminal/cleanup.mjs" }
239
- };
240
- function resolveHarnessDispatch(adapter, action, target) {
241
- if (action === "install") {
242
- const { entry, fallback } = INJECT_CANDIDATES[adapter];
243
- if (adapter === "extension" || adapter === "terminal") {
244
- return { command: process.execPath, prefixArgs: [resolveEntry(runnerDir, [entry, fallback], "file")] };
245
- }
246
- return { command: resolveEntry(runnerDir, [entry, fallback], "file"), prefixArgs: [] };
247
- }
248
- if (action === "cleanup") {
249
- const { entry, fallback } = CLEANUP_CANDIDATES[adapter];
250
- const base = adapter === "terminal" ? runnerDir : installedRunnerSource(target, adapter) ?? runnerDir;
251
- if (adapter === "extension" || adapter === "terminal") {
252
- return { command: process.execPath, prefixArgs: [resolveEntry(base, [entry, fallback], "file")] };
253
- }
254
- return { command: resolveEntry(base, [entry, fallback], "file"), prefixArgs: [] };
255
- }
256
- if (adapter === "terminal" && action === "verify") {
257
- return { command: process.execPath, prefixArgs: [path.join(runnerDir, "adapters", "terminal", "verify.mjs")] };
258
- }
259
- if (adapter === "core" && action === "verify") {
260
- const delegate = path.join(recipeHarnessPath(target, "core"), "runner", "bin", "mm-harness");
261
- if (!isExecutable(delegate)) {
262
- return {
263
- error: `core recipe harness not installed: missing delegate ${delegate}.
264
- Next: mm-harness install --platform core --target ${target}`
265
- };
266
- }
267
- return { command: delegate, prefixArgs: ["doctor", "--adapter", "core"] };
268
- }
269
- return { inProcess: adapter === "mobile" ? "mobile-verify" : "extension-verify" };
192
+ function resolveHarnessDispatch(surface, action, target) {
193
+ if (action === "verify") return surface.harness.verify(target);
194
+ const leaf = action === "install" ? surface.harness.install : surface.harness.cleanup;
195
+ const base = action === "cleanup" && surface.harness.cleanup.fromInstalledRunner ? installedRunnerSource(target, surface.adapter) ?? runnerDir : runnerDir;
196
+ const entry = resolveEntry(base, [leaf.entry, leaf.fallback], "file");
197
+ return leaf.node ? { command: process.execPath, prefixArgs: [entry] } : { command: entry, prefixArgs: [] };
270
198
  }
271
199
  async function handleHarness(argv) {
272
200
  const action = argv[0];
@@ -280,6 +208,7 @@ async function handleHarness(argv) {
280
208
  return 2;
281
209
  }
282
210
  const harnessAction = action;
211
+ const { getAdapterSurface } = await import("./adapters/surface.js");
283
212
  const { adapter: parsedAdapter, json, quiet, forward } = parseHarnessArgs(argv.slice(1));
284
213
  const rawTarget = argValue(forward, "--target");
285
214
  const target = path.resolve(rawTarget ?? process.cwd());
@@ -306,10 +235,11 @@ async function handleHarness(argv) {
306
235
  if (harnessAction === "install" && hasArg(forward, "--runway")) {
307
236
  return handleRunwayInstall(adapter, target, forward, json);
308
237
  }
309
- if (adapter === "extension" && harnessAction === "verify") {
310
- forwardArgs = applyExtensionRuntimeEnv(target, forwardArgs);
238
+ const surface = getAdapterSurface(adapter);
239
+ if (harnessAction === "verify" && surface.harness.verifyArgs) {
240
+ forwardArgs = surface.harness.verifyArgs(target, forwardArgs);
311
241
  }
312
- const dispatch = resolveHarnessDispatch(adapter, harnessAction, target);
242
+ const dispatch = resolveHarnessDispatch(surface, harnessAction, target);
313
243
  if ("error" in dispatch) {
314
244
  if (quiet) {
315
245
  return 1;
@@ -335,7 +265,7 @@ async function handleHarness(argv) {
335
265
  }
336
266
  const start = Date.now();
337
267
  if ("inProcess" in dispatch) {
338
- const runVerify = dispatch.inProcess === "mobile-verify" ? (await import("./adapters/mobile/verify.js")).runMobileVerify : (await import("./adapters/extension/verify.js")).runExtensionVerify;
268
+ const runVerify = await dispatch.inProcess();
339
269
  let exitCode;
340
270
  try {
341
271
  exitCode = await runVerify(forwardArgs, {
@@ -355,7 +285,7 @@ async function handleHarness(argv) {
355
285
  `);
356
286
  exitCode = 1;
357
287
  }
358
- return reportHarnessOutcome(harnessAction, adapter, target, exitCode, autoDetected, json, quiet, start);
288
+ return reportHarnessOutcome(harnessAction, surface, target, exitCode, autoDetected, json, quiet, start);
359
289
  }
360
290
  if (shellLeafMissing(dispatch.command)) {
361
291
  const message = missingShellLeafMessage(dispatch.command);
@@ -366,7 +296,7 @@ async function handleHarness(argv) {
366
296
  harnessSummary(harnessAction, adapter, target, "fail", 1, autoDetected, {
367
297
  code: "HARNESS_SPAWN_FAILED",
368
298
  message,
369
- userAction: failureHint(adapter, harnessAction)
299
+ userAction: failureHint(surface, harnessAction)
370
300
  })
371
301
  );
372
302
  } else {
@@ -387,19 +317,20 @@ async function handleHarness(argv) {
387
317
  harnessSummary(harnessAction, adapter, target, "fail", 1, autoDetected, {
388
318
  code: "HARNESS_SPAWN_FAILED",
389
319
  message: `${harnessAction} ${adapter} could not start: ${result.error.message}`,
390
- userAction: failureHint(adapter, harnessAction)
320
+ userAction: failureHint(surface, harnessAction)
391
321
  })
392
322
  );
393
323
  } else {
394
324
  console.error(`\u2717 ${harnessAction} ${adapter} could not start: ${result.error.message}
395
- ${failureHint(adapter, harnessAction)}`);
325
+ ${failureHint(surface, harnessAction)}`);
396
326
  }
397
327
  return 1;
398
328
  }
399
- return reportHarnessOutcome(harnessAction, adapter, target, result.status ?? 1, autoDetected, json, quiet, start);
329
+ return reportHarnessOutcome(harnessAction, surface, target, result.status ?? 1, autoDetected, json, quiet, start);
400
330
  }
401
- function reportHarnessOutcome(action, adapter, target, exitCode, autoDetected, json, quiet, start) {
331
+ function reportHarnessOutcome(action, surface, target, exitCode, autoDetected, json, quiet, start) {
402
332
  const seconds = ((Date.now() - start) / 1e3).toFixed(1);
333
+ const adapter = surface.adapter;
403
334
  if (quiet) {
404
335
  return exitCode;
405
336
  } else if (json) {
@@ -411,7 +342,7 @@ function reportHarnessOutcome(action, adapter, target, exitCode, autoDetected, j
411
342
  exitCode === 0 ? "pass" : "fail",
412
343
  exitCode,
413
344
  autoDetected,
414
- exitCode === 0 ? void 0 : { code: "HARNESS_FAILED", message: `${action} ${adapter} failed (exit ${exitCode})`, userAction: failureHint(adapter, action) }
345
+ exitCode === 0 ? void 0 : { code: "HARNESS_FAILED", message: `${action} ${adapter} failed (exit ${exitCode})`, userAction: failureHint(surface, action) }
415
346
  )
416
347
  );
417
348
  } else if (exitCode === 0) {
@@ -419,7 +350,7 @@ function reportHarnessOutcome(action, adapter, target, exitCode, autoDetected, j
419
350
  Next: ${successNext(adapter, action, target)}`));
420
351
  } else {
421
352
  console.error(colorHumanMessage(`\u2717 ${action} ${adapter} failed (exit ${exitCode}, ${seconds}s)
422
- ${failureHint(adapter, action)}`));
353
+ ${failureHint(surface, action)}`));
423
354
  }
424
355
  return exitCode;
425
356
  }
@@ -448,7 +379,7 @@ async function handleRunwayInstall(adapter, target, forward, json) {
448
379
  });
449
380
  const provisionedSimulator = typeof result.simulator === "object" && result.simulator ? result.simulator : void 0;
450
381
  const provisionedDevice = typeof provisionedSimulator?.udid === "string" ? provisionedSimulator.udid : typeof provisionedSimulator?.name === "string" ? provisionedSimulator.name : void 0;
451
- const next = result.status === "pass" && adapter === "mobile" && result.resolveOnly !== true ? `mm-harness launch ${String(result.platform ?? "ios")} --adapter mobile --target ${shellQuote(target)}` + (provisionedDevice ? ` --device ${shellQuote(provisionedDevice)}` : "") : void 0;
382
+ const next = result.status === "pass" && surface.runwayProvision.launchNext && result.resolveOnly !== true ? `mm-harness launch ${String(result.platform ?? "ios")} --adapter ${adapter} --target ${shellQuote(target)}` + (provisionedDevice ? ` --device ${shellQuote(provisionedDevice)}` : "") : void 0;
452
383
  const output = next ? { ...result, next } : result;
453
384
  if (json) {
454
385
  console.log(JSON.stringify(output, null, 2));
@@ -1032,8 +1032,9 @@ function detectedSlotLine(out) {
1032
1032
  if (ctx.slotId) parts.push(`slot ${out("ok", String(ctx.slotId))}`);
1033
1033
  if (ctx.simulator) parts.push(`device ${out("ok", String(ctx.simulator))}`);
1034
1034
  const devServerPort = ctx.watcherPort ?? ctx.devServerPort ?? ctx.metroPort;
1035
- if (adapter && adapter !== "core" && devServerPort) {
1036
- parts.push(`${getAdapterSurface(adapter).devServer.label} :${out("ok", String(devServerPort))}`);
1035
+ const surface = adapter ? getAdapterSurface(adapter) : void 0;
1036
+ if (surface && !surface.headless && devServerPort) {
1037
+ parts.push(`${surface.devServer.label} :${out("ok", String(devServerPort))}`);
1037
1038
  }
1038
1039
  if (ctx.gitBranch) parts.push(`branch ${out("info", String(ctx.gitBranch))}`);
1039
1040
  if (parts.length === 0) return null;
package/dist/paths.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { fileURLToPath, pathToFileURL } from "node:url";
4
+ import { adapterRegistry } from "./adapters/registry.js";
4
5
  const runnerDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
5
6
  const pathDefaults = readPathDefaults();
6
7
  const DEFAULT_RECIPE_RUNTIME_DIR = pathDefaults.recipeRuntimeDir;
@@ -88,8 +89,13 @@ function isProtocolRoot(candidate) {
88
89
  return fs.existsSync(path.join(candidate, "packages/recipe-harness/package.json")) && fs.existsSync(path.join(candidate, "packages/protocol/package.json"));
89
90
  }
90
91
  function assertAdapter(adapter) {
91
- if (adapter !== "mobile" && adapter !== "extension" && adapter !== "core" && adapter !== "terminal") {
92
- throw new Error("Adapter must be mobile, extension, core, or terminal.");
92
+ const ids = adapterRegistry.list();
93
+ if (ids.length === 0) {
94
+ throw new Error("internal: no adapters are registered yet; import src/adapters/surface.ts before validating an adapter");
95
+ }
96
+ if (typeof adapter !== "string" || !adapterRegistry.has(adapter)) {
97
+ const choices = ids.length > 1 ? `${ids.slice(0, -1).join(", ")}, or ${ids.at(-1)}` : ids.join("");
98
+ throw new Error(`Adapter must be ${choices}.`);
93
99
  }
94
100
  }
95
101
  function manifestPath(adapter) {
@@ -3,6 +3,7 @@ import { execFileSync } from "node:child_process";
3
3
  import fs from "node:fs";
4
4
  import os from "node:os";
5
5
  import path from "node:path";
6
+ import { getAdapterSurface } from "./adapters/surface.js";
6
7
  import { recipeRuntimeDir } from "./paths.js";
7
8
  function ensureRuntimeContext(target, adapter) {
8
9
  const repoRoot = fs.realpathSync(target);
@@ -91,7 +92,7 @@ function validExistingContext(context, repoRoot, runtimeDir, adapter) {
91
92
  if (runtimeStart[field] !== void 0 && (typeof runtimeStart[field] !== "string" || runtimeStart[field].length === 0)) return false;
92
93
  }
93
94
  }
94
- const forbiddenResources = adapter === "core" ? ["cdpPort", "watcherPort", "metroPort", "devServerPort", "simulator", "simulatorUdid", "adbSerial", "extensionId"] : adapter === "extension" ? ["metroPort", "simulator", "simulatorUdid", "adbSerial"] : adapter === "terminal" ? ["metroPort", "simulator", "simulatorUdid", "adbSerial", "extensionId"] : ["cdpPort", "extensionId"];
95
+ const forbiddenResources = getAdapterSurface(adapter).runtimeContext.forbiddenFields;
95
96
  if (forbiddenResources.some((field) => context[field] !== void 0)) return false;
96
97
  return true;
97
98
  }
@@ -104,11 +105,12 @@ function localDefaults(repoRoot, adapter, existing) {
104
105
  const digest = createHash("sha256").update(repoRoot).digest("hex");
105
106
  const offset = Number.parseInt(digest.slice(0, 8), 16) % 1e4;
106
107
  const defaults = { slotId: `local-${adapter}-${digest.slice(0, 8)}` };
107
- if (adapter === "extension" && !numericEnv("RECIPE_CDP_PORT", "CDP_PORT") && !existing.cdpPort) {
108
- defaults.cdpPort = claimLocalPort(repoRoot, "cdp", 2e4 + offset);
108
+ const localPorts = getAdapterSurface(adapter).runtimeContext.localPorts ?? {};
109
+ if (localPorts.cdp !== void 0 && !numericEnv("RECIPE_CDP_PORT", "CDP_PORT") && !existing.cdpPort) {
110
+ defaults.cdpPort = claimLocalPort(repoRoot, "cdp", localPorts.cdp + offset);
109
111
  }
110
- if (adapter !== "core" && adapter !== "terminal" && !numericEnv("WATCHER_PORT", "METRO_PORT", "RECIPE_WATCHER_PORT") && !existing.watcherPort && !existing.metroPort && !existing.devServerPort) {
111
- defaults.watcherPort = claimLocalPort(repoRoot, "watcher", (adapter === "extension" ? 3e4 : 4e4) + offset);
112
+ if (localPorts.watcher !== void 0 && !numericEnv("WATCHER_PORT", "METRO_PORT", "RECIPE_WATCHER_PORT") && !existing.watcherPort && !existing.metroPort && !existing.devServerPort) {
113
+ defaults.watcherPort = claimLocalPort(repoRoot, "watcher", localPorts.watcher + offset);
112
114
  }
113
115
  return defaults;
114
116
  }
@@ -158,21 +160,14 @@ function portClaimIsLive(claim, port) {
158
160
  }
159
161
  }
160
162
  function runtimeResources(adapter, existing, defaults) {
161
- if (adapter === "core") return {};
163
+ const { resources } = getAdapterSurface(adapter).runtimeContext;
164
+ if (!resources) return {};
162
165
  const watcherPort = numericEnv("WATCHER_PORT", "METRO_PORT", "RECIPE_WATCHER_PORT") ?? existing.watcherPort ?? existing.metroPort ?? existing.devServerPort ?? defaults.watcherPort;
163
- const shared = {
166
+ return {
164
167
  watcherPort,
165
- devServerPort: watcherPort
168
+ devServerPort: watcherPort,
169
+ ...resources({ watcherPort, existing, defaults, envPort: numericEnv })
166
170
  };
167
- if (adapter === "extension" || adapter === "terminal") {
168
- shared.cdpPort = numericEnv("RECIPE_CDP_PORT", "CDP_PORT") ?? existing.cdpPort ?? defaults.cdpPort;
169
- } else {
170
- shared.metroPort = watcherPort;
171
- shared.simulator = process.env.IOS_SIMULATOR || existing.simulator;
172
- shared.simulatorUdid = process.env.SIM_UDID || existing.simulatorUdid;
173
- shared.adbSerial = process.env.ADB_SERIAL || process.env.ANDROID_SERIAL || existing.adbSerial;
174
- }
175
- return shared;
176
171
  }
177
172
  function numericEnv(...names) {
178
173
  for (const name of names) {