@deeeed/metamask-harness 0.74.0 → 0.76.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 (96) hide show
  1. package/CHANGELOG.md +39 -1
  2. package/README.md +1 -1
  3. package/adapters/shared/ensure-runner-deps.sh +15 -3
  4. package/adapters/shared/reap-checkout-metros.sh +9 -0
  5. package/dist/action-values.js +15 -0
  6. package/dist/adapters/core/actions.js +20 -0
  7. package/dist/adapters/core/live-script-env.js +36 -0
  8. package/dist/adapters/core/overlay.js +34 -0
  9. package/dist/adapters/core/readiness.js +8 -0
  10. package/dist/adapters/core/run.js +54 -0
  11. package/dist/adapters/core/surface.js +12 -1
  12. package/dist/adapters/extension/action-inputs.js +206 -0
  13. package/dist/adapters/extension/actions.js +51 -0
  14. package/dist/adapters/extension/console-capture.js +13 -1
  15. package/dist/adapters/extension/doctor-checks.js +135 -0
  16. package/dist/adapters/extension/fixtures.js +103 -0
  17. package/dist/adapters/extension/launch.js +315 -0
  18. package/dist/adapters/extension/launched-browser.js +51 -0
  19. package/dist/adapters/extension/overlay.js +55 -0
  20. package/dist/adapters/extension/readiness.js +32 -0
  21. package/dist/adapters/extension/reload.js +40 -0
  22. package/dist/adapters/extension/run.js +105 -0
  23. package/dist/adapters/extension/surface.js +31 -3
  24. package/dist/adapters/harness-freshness.js +4 -2
  25. package/dist/adapters/mobile/actions.js +54 -0
  26. package/dist/adapters/mobile/devices.js +21 -0
  27. package/dist/adapters/mobile/doctor-checks.js +40 -0
  28. package/dist/adapters/mobile/fixtures.js +141 -0
  29. package/dist/adapters/mobile/launch.js +192 -0
  30. package/dist/adapters/mobile/lifecycle.js +300 -0
  31. package/dist/adapters/mobile/overlay.js +27 -0
  32. package/dist/adapters/mobile/prepare-target.js +38 -0
  33. package/dist/adapters/mobile/readiness.js +39 -0
  34. package/dist/adapters/mobile/reload.js +15 -0
  35. package/dist/adapters/mobile/run.js +156 -0
  36. package/dist/adapters/mobile/runtime-identity.js +34 -0
  37. package/dist/adapters/mobile/surface.js +39 -3
  38. package/dist/adapters/mobile/ui.js +701 -0
  39. package/dist/adapters/performance/cdp-trace.js +24 -6
  40. package/dist/adapters/performance/device-platform.js +10 -0
  41. package/dist/adapters/registry.js +1 -1
  42. package/dist/adapters/terminal/actions.js +43 -0
  43. package/dist/adapters/terminal/console-capture.js +14 -1
  44. package/dist/adapters/terminal/doctor-checks.js +22 -0
  45. package/dist/adapters/terminal/overlay.js +18 -0
  46. package/dist/adapters/terminal/readiness.js +11 -0
  47. package/dist/adapters/terminal/run.js +30 -0
  48. package/dist/adapters/terminal/surface.js +21 -3
  49. package/dist/adapters.js +32 -824
  50. package/dist/cli-commands.js +1 -0
  51. package/dist/cli.js +3 -1
  52. package/dist/command-contract.js +3 -0
  53. package/dist/commands/call.js +2 -5
  54. package/dist/commands/debug.js +6 -25
  55. package/dist/commands/device-target.js +2 -1
  56. package/dist/commands/doctor.js +26 -137
  57. package/dist/commands/fixtures.js +13 -217
  58. package/dist/commands/launch/device.js +2 -6
  59. package/dist/commands/launch/extension.js +3 -303
  60. package/dist/commands/launch/index.js +1 -1
  61. package/dist/commands/launch/mobile.js +2 -190
  62. package/dist/commands/launch/quote.js +7 -0
  63. package/dist/commands/logs.js +1 -1
  64. package/dist/commands/parse-args.js +9 -4
  65. package/dist/commands/pin.js +226 -0
  66. package/dist/commands/prepare.js +23 -53
  67. package/dist/commands/provision.js +3 -7
  68. package/dist/commands/reload.js +8 -47
  69. package/dist/commands/run-engine.js +38 -138
  70. package/dist/commands/run-report.js +4 -48
  71. package/dist/commands/run.js +6 -48
  72. package/dist/commands/runtime-decision.js +5 -27
  73. package/dist/commands/self-test.js +1 -3
  74. package/dist/commands/status.js +20 -28
  75. package/dist/commands/stop.js +1 -1
  76. package/dist/doctor.js +12 -120
  77. package/dist/execution-provenance.js +3 -2
  78. package/dist/harness-pin.js +10 -0
  79. package/dist/harness.js +31 -100
  80. package/dist/heal-bounds.js +3 -2
  81. package/dist/live-adapter-contract.js +8 -34
  82. package/dist/manifest.js +3 -5
  83. package/dist/metamask-action-validation.js +5 -196
  84. package/dist/mm-harness-cli.js +30 -4
  85. package/dist/network-observation.js +10 -3
  86. package/dist/paths.js +8 -2
  87. package/dist/performance-observation.js +15 -22
  88. package/dist/pr-body/render.js +3 -1
  89. package/dist/recording-target.js +4 -39
  90. package/dist/run-diagnostics.js +21 -60
  91. package/dist/run-recording.js +5 -4
  92. package/dist/runner.js +20 -368
  93. package/dist/runtime-context.js +12 -17
  94. package/docs/CONTRIBUTING.md +29 -7
  95. package/package.json +2 -1
  96. package/scripts/completions.sh +2 -1
@@ -1,4 +1,3 @@
1
- import fs from "node:fs";
2
1
  import { prepareMobile } from "../../adapters/mobile/prepare.js";
3
2
  import {
4
3
  mobileSourceCheck,
@@ -8,11 +7,7 @@ import { ensureHarnessFresh } from "../../adapters/harness-freshness.js";
8
7
  import { pinMobileFeatureFlags } from "../../adapters/mobile/feature-flags.js";
9
8
  import { toRemoteFlagSpec } from "../../feature-flags.js";
10
9
  import { clearMobileReleaseArtifactState } from "../../adapters/mobile/release-artifact-state.js";
11
- import { EXIT, str, writeInteractiveProgress } from "../shared.js";
12
- import { applyDeviceTargeting } from "../device-target.js";
13
- import { RECOVERY_CODE } from "../../heal-bounds.js";
14
- import { recipeRuntimePath } from "../../paths.js";
15
- import { shellQuote } from "./device.js";
10
+ import { EXIT } from "../shared.js";
16
11
  async function launchMobile(target, mobileTarget, tier, json, restartApp = false, clearMetro = false, remoteFlags) {
17
12
  clearMobileReleaseArtifactState(target);
18
13
  await ensureHarnessFresh(target, "mobile");
@@ -59,189 +54,6 @@ async function launchMobile(target, mobileTarget, tier, json, restartApp = false
59
54
  }
60
55
  return { ...result, remoteFeatureFlags: provenFlags };
61
56
  }
62
- function mobileProvisioningBlocked(output) {
63
- return /open-device: configured iOS simulator '.+' does not exist|open-device: no MetaMask bundle found|fast mode requires an installed (?:iOS dev client|Android dev client)/u.test(output);
64
- }
65
- function missingConfiguredIosSimulator(output) {
66
- return /open-device: configured iOS simulator '([^']+)' does not exist/u.exec(output)?.[1];
67
- }
68
- function ambiguousConfiguredIosSimulator(output) {
69
- return /open-device: iOS simulator target ("[^"]+") is ambiguous/u.exec(output)?.[1];
70
- }
71
- function mobileBridgeTargetMissing(output) {
72
- return /no bridge target matched|wait-for-bridge: readiness deadline expired/iu.test(output);
73
- }
74
- function mobileProvisionCommand(target, mobileTarget) {
75
- const platform = mobileTarget === "android" ? "android" : "ios";
76
- const device = platform === "ios" ? process.env.SIM_UDID || process.env.IOS_SIMULATOR : process.env.ADB_SERIAL || process.env.ANDROID_SERIAL || process.env.ANDROID_DEVICE;
77
- const parts = ["mm-harness", "provision", "runway", platform, "--adapter", "mobile", "--target", shellQuote(target)];
78
- if (device) parts.push("--device", shellQuote(device));
79
- return parts.join(" ");
80
- }
81
- function isIosSimulatorUdid(value) {
82
- return /^[0-9A-Fa-f-]{36}$/u.test(value);
83
- }
84
- function launchDeviceLabel(mobileTarget) {
85
- if (mobileTarget === "android") {
86
- return process.env.ANDROID_TARGET_DEVICE_NAME || process.env.ADB_SERIAL || process.env.ANDROID_SERIAL || process.env.ANDROID_DEVICE || "android device";
87
- }
88
- if (mobileTarget === "ios") {
89
- return process.env.IOS_SIMULATOR || process.env.SIM_UDID || "iOS simulator";
90
- }
91
- return process.env.IOS_SIMULATOR || process.env.ADB_SERIAL || "booted device";
92
- }
93
- function metroLaunchEvidence(target) {
94
- const evidencePath = recipeRuntimePath(target, "metro-generation.json");
95
- let descriptor;
96
- try {
97
- descriptor = fs.openSync(evidencePath, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
98
- const stat = fs.fstatSync(descriptor);
99
- if (!stat.isFile() || stat.size > 256 * 1024) return void 0;
100
- return JSON.parse(fs.readFileSync(descriptor, "utf8"));
101
- } catch {
102
- return void 0;
103
- } finally {
104
- if (descriptor !== void 0) fs.closeSync(descriptor);
105
- }
106
- }
107
- const mobileLaunchProfile = {
108
- refuseFlags({ mobileTarget }) {
109
- if (!mobileTarget) {
110
- return { message: "target is required for mobile.", userAction: "mm-harness launch ios or mm-harness launch android" };
111
- }
112
- return void 0;
113
- },
114
- applyEnv({ adapter, options, mobileTarget, target }) {
115
- const device = str(options, "device");
116
- if (!device && mobileTarget !== "ios" && mobileTarget !== "android") return void 0;
117
- const targetingOptions = {
118
- ...options,
119
- ...mobileTarget ? { platform: mobileTarget } : {}
120
- };
121
- const result = applyDeviceTargeting("launch", adapter, targetingOptions, { gate: false, rerun: "" });
122
- if ("code" in result) {
123
- if (mobileTarget === "ios" && result.code === "DEVICE_NOT_FOUND") {
124
- process.env.IOS_SIMULATOR = device;
125
- process.env.SIM_UDID = isIosSimulatorUdid(device) ? device : "";
126
- process.env.ADB_SERIAL = "";
127
- process.env.ANDROID_SERIAL = "";
128
- process.env.ANDROID_DEVICE = "";
129
- process.env.ANDROID_TARGET_DEVICE_NAME = "";
130
- } else {
131
- return result;
132
- }
133
- }
134
- if (mobileTarget === "android" && device && !process.env.ADB_SERIAL && !process.env.ANDROID_SERIAL) {
135
- return {
136
- ok: false,
137
- code: "DEVICE_WRONG_PLATFORM",
138
- message: `--device ${device} did not resolve to an Android device for launch android.`,
139
- userAction: `mm-harness status --target ${shellQuote(target)} --all-devices --json`
140
- };
141
- }
142
- if (mobileTarget === "ios" && device && !process.env.IOS_SIMULATOR) {
143
- return {
144
- ok: false,
145
- code: "DEVICE_WRONG_PLATFORM",
146
- message: `--device ${device} did not resolve to an iOS simulator for launch ios.`,
147
- userAction: `mm-harness status --target ${shellQuote(target)} --all-devices --json`
148
- };
149
- }
150
- return void 0;
151
- },
152
- start({ options, mobileTarget, machine, tier, wantClearMetro }) {
153
- 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";
154
- const watcherPort = str(options, "watcherPort") ?? process.env.WATCHER_PORT ?? "auto";
155
- writeInteractiveProgress(
156
- machine,
157
- `\u2192 mobile launch \u2014 ${mobileTarget} \xB7 ${tier === "build" ? "native build" : "quick readiness"} \xB7 ${device} \xB7 Metro ${watcherPort === "auto" ? "auto" : `:${watcherPort}`}${wantClearMetro ? " \xB7 clear cache" : ""}`
158
- );
159
- },
160
- nativeInputs: ["ios", "android", "package.json", "yarn.lock"],
161
- rebuildCommand: ({ mobileTarget }) => `mm-harness launch ${mobileTarget} --build`,
162
- compose: ({ target, mobileTarget, machine, remoteFlagRequest }, tier, restartApp, clearMetro) => launchMobile(target, mobileTarget, tier, machine, restartApp, clearMetro, remoteFlagRequest?.flags),
163
- classify(_launch, output) {
164
- if (output.includes("open-device: Android device is locked.")) {
165
- return {
166
- code: "MOBILE_DEVICE_LOCKED",
167
- message: "the selected Android device is locked.",
168
- recoverable: false,
169
- userAction: "Unlock the selected Android device, then retry the launch.",
170
- exitCode: EXIT.runtime,
171
- originalError: output.trim()
172
- };
173
- }
174
- if (/open-device: Android (?:lock state unavailable|display preparation failed)\./u.test(output)) {
175
- return {
176
- code: "MOBILE_DEVICE_UI_UNAVAILABLE",
177
- message: "the selected Android display could not be prepared.",
178
- recoverable: false,
179
- userAction: "Check adb access and the selected device screen, then retry the launch.",
180
- exitCode: EXIT.runtime,
181
- originalError: output.trim()
182
- };
183
- }
184
- return void 0;
185
- },
186
- async beforeHeal(launch, attempt, state) {
187
- const { target, mobileTarget, tier } = launch;
188
- const output = attempt.output;
189
- const missingIosSimulator = missingConfiguredIosSimulator(output);
190
- const ambiguousIosSimulator = ambiguousConfiguredIosSimulator(output);
191
- if (ambiguousIosSimulator) {
192
- return launch.fail(state, {
193
- code: "MOBILE_DEVICE_AMBIGUOUS",
194
- message: `more than one iOS simulator matches ${ambiguousIosSimulator}.`,
195
- recoverable: false,
196
- userAction: `mm-harness status --adapter mobile --target ${shellQuote(target)} --all-devices --json`,
197
- exitCode: EXIT.runtime,
198
- originalError: output.trim() || void 0
199
- });
200
- }
201
- if (missingIosSimulator && isIosSimulatorUdid(missingIosSimulator)) {
202
- return launch.fail(state, {
203
- code: "MOBILE_DEVICE_NOT_FOUND",
204
- message: `the configured iOS simulator ${missingIosSimulator} no longer exists.`,
205
- recoverable: false,
206
- userAction: `mm-harness status --adapter mobile --target ${shellQuote(target)} --all-devices --json`,
207
- exitCode: EXIT.runtime,
208
- originalError: output.trim() || void 0
209
- });
210
- }
211
- if (mobileProvisioningBlocked(output)) {
212
- return launch.fail(state, {
213
- code: "MOBILE_PROVISION_REQUIRED",
214
- message: `mobile ${mobileTarget ?? "runtime"} is not provisioned for this slot.`,
215
- recoverable: false,
216
- userAction: mobileProvisionCommand(target, mobileTarget),
217
- exitCode: EXIT.runtime,
218
- originalError: output.trim() || void 0
219
- });
220
- }
221
- if (tier === "build" && /open-device: (?:Android|iOS) native build failed/u.test(output)) {
222
- return launch.fail(state, {
223
- code: "MOBILE_NATIVE_BUILD_FAILED",
224
- message: "the selected Mobile native build failed; refusing to repeat the expensive build as a Metro recovery.",
225
- recoverable: false,
226
- exitCode: EXIT.runtime,
227
- originalError: output.trim() || void 0
228
- });
229
- }
230
- return void 0;
231
- },
232
- recovery(_launch, output) {
233
- const restartApp = mobileBridgeTargetMissing(output);
234
- return { code: restartApp ? "mobile.app-restarted" : RECOVERY_CODE.mobile, restartApp };
235
- },
236
- // Mobile proved its set inside launchMobile (null: nothing requested and no
237
- // runtime answered).
238
- provenFlags: (_launch, requested, proven) => ({ flags: proven === void 0 ? requested : proven }),
239
- verifyArgs: ({ mobileTarget }) => mobileTarget ? ["--platform", mobileTarget] : [],
240
- platformLabel: ({ mobileTarget }) => mobileTarget ?? null,
241
- deviceLabel: ({ mobileTarget }) => launchDeviceLabel(mobileTarget),
242
- evidence: metroLaunchEvidence
243
- };
244
57
  export {
245
- launchMobile,
246
- mobileLaunchProfile
58
+ launchMobile
247
59
  };
@@ -0,0 +1,7 @@
1
+ function shellQuote(value) {
2
+ if (/^[A-Za-z0-9_./:@%+=,-]+$/u.test(value)) return value;
3
+ return `'${value.replace(/'/gu, `'"'"'`)}'`;
4
+ }
5
+ export {
6
+ shellQuote
7
+ };
@@ -68,7 +68,7 @@ async function handleLogs(argv) {
68
68
  if (flag(options, "window")) {
69
69
  surface.resolveSlotPorts(target);
70
70
  const port = process.env.WATCHER_PORT ?? process.env.METRO_PORT ?? "default";
71
- const windowName = adapter === "mobile" ? `metro-${port}` : `webpack-${port}`;
71
+ const windowName = `${surface.devServer.label}-${port}`;
72
72
  const devLog = logSources[0].path;
73
73
  const leaf = path.join(runnerDir, "adapters/shared/open-log-window.sh");
74
74
  const result = spawnScript(
@@ -1,6 +1,7 @@
1
1
  import path from "node:path";
2
2
  import { detectAdapter } from "../harness.js";
3
- import { assertAdapter, manifestPath } from "../paths.js";
3
+ import { adapterRegistry } from "../adapters/registry.js";
4
+ import { assertAdapter } from "../paths.js";
4
5
  import { explicitRecipeTrustOptions } from "../recipe-security.js";
5
6
  import { ADAPTER_DETECT_NEXT } from "./shared.js";
6
7
  import { EXIT } from "./shared.js";
@@ -179,12 +180,16 @@ function requiredOption(options, key, message) {
179
180
  if (!value) throw usageError(message);
180
181
  return value;
181
182
  }
183
+ function adapterUsageError(error) {
184
+ if (error instanceof Error && error.message.startsWith("internal:")) return error;
185
+ return usageError(error instanceof Error ? error.message : String(error));
186
+ }
182
187
  function adapterOption(options) {
183
188
  const adapter = optionString(options, "adapter");
184
189
  try {
185
190
  assertAdapter(adapter);
186
191
  } catch (error) {
187
- throw usageError(error instanceof Error ? error.message : String(error));
192
+ throw adapterUsageError(error);
188
193
  }
189
194
  return adapter;
190
195
  }
@@ -193,7 +198,7 @@ function targetPath(options) {
193
198
  }
194
199
  function actionManifestPathOption(options, adapter) {
195
200
  const configured = optionString(options, "actionManifest");
196
- return configured ? path.resolve(configured) : manifestPath(adapter);
201
+ return configured ? path.resolve(configured) : adapterRegistry.get(adapter).actions.manifestPath();
197
202
  }
198
203
  function parsePort(value, errorMessage) {
199
204
  const port = Number(value);
@@ -238,7 +243,7 @@ function resolveAdapter(options) {
238
243
  try {
239
244
  assertAdapter(adapter);
240
245
  } catch (error) {
241
- throw usageError(error instanceof Error ? error.message : String(error));
246
+ throw adapterUsageError(error);
242
247
  }
243
248
  return { adapter, target };
244
249
  }
@@ -0,0 +1,226 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import crypto from "node:crypto";
3
+ import fs from "node:fs";
4
+ import { createRequire } from "node:module";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import { isPin, PIN_MARKER } from "../harness-pin.js";
8
+ import { runnerDir } from "../paths.js";
9
+ import { EXIT } from "./shared.js";
10
+ const PACKAGE_NAME = "@deeeed/metamask-harness";
11
+ const STALE_STAGING_MS = 30 * 60 * 1e3;
12
+ const RUNNER_STATE = /* @__PURE__ */ new Set([".omc", ".omx"]);
13
+ function pinRoot(env = process.env, home = os.homedir()) {
14
+ return path.resolve(env.MM_HARNESS_PIN_ROOT || path.join(home, ".local/share/mm-harness/pins"));
15
+ }
16
+ function readManifest(root) {
17
+ return JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"));
18
+ }
19
+ function contentDigest(root) {
20
+ const hash = crypto.createHash("sha256");
21
+ const walk = (directory, relative) => {
22
+ hash.update(`d\0${relative}\0`);
23
+ const entries = fs.readdirSync(directory, { withFileTypes: true }).sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0);
24
+ for (const entry of entries) {
25
+ if (RUNNER_STATE.has(entry.name) || !relative && entry.name === PIN_MARKER) continue;
26
+ const absolute = path.join(directory, entry.name);
27
+ const child = relative ? `${relative}/${entry.name}` : entry.name;
28
+ if (entry.isDirectory()) {
29
+ walk(absolute, child);
30
+ } else if (entry.isSymbolicLink()) {
31
+ hash.update(`l\0${child}\0${fs.readlinkSync(absolute)}\0`);
32
+ } else if (entry.isFile()) {
33
+ const fd = fs.openSync(absolute, "r");
34
+ try {
35
+ const opened = fs.fstatSync(fd);
36
+ hash.update(`f\0${child}\0${opened.mode & 73 ? "x" : "-"}\0${opened.size}\0`);
37
+ hash.update(fs.readFileSync(fd));
38
+ hash.update("\0");
39
+ } finally {
40
+ fs.closeSync(fd);
41
+ }
42
+ }
43
+ }
44
+ };
45
+ walk(root, "");
46
+ return hash.digest("hex");
47
+ }
48
+ function relinkIntoCopy(copy, sourceAliases) {
49
+ const walk = (directory) => {
50
+ for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
51
+ const absolute = path.join(directory, entry.name);
52
+ if (entry.isDirectory()) {
53
+ walk(absolute);
54
+ continue;
55
+ }
56
+ if (!entry.isSymbolicLink()) continue;
57
+ const raw = fs.readlinkSync(absolute);
58
+ if (!path.isAbsolute(raw)) continue;
59
+ const targets = [path.resolve(raw)];
60
+ try {
61
+ targets.push(fs.realpathSync(raw));
62
+ } catch {
63
+ }
64
+ let match;
65
+ for (const target of targets) {
66
+ const alias = sourceAliases.find((prefix) => target === prefix || target.startsWith(`${prefix}${path.sep}`));
67
+ if (alias) {
68
+ match = { alias, target };
69
+ break;
70
+ }
71
+ }
72
+ if (!match) continue;
73
+ fs.unlinkSync(absolute);
74
+ fs.symlinkSync(path.relative(directory, path.join(copy, path.relative(match.alias, match.target))) || ".", absolute);
75
+ }
76
+ };
77
+ walk(copy);
78
+ }
79
+ function assertSelfContained(root) {
80
+ const manifest = readManifest(root);
81
+ const lookup = createRequire(path.join(root, "package.json")).resolve.paths("") ?? [];
82
+ const realRoot = fs.realpathSync(root);
83
+ const inside = (candidate) => candidate === realRoot || candidate.startsWith(`${realRoot}${path.sep}`);
84
+ for (const dependency of Object.keys(manifest.dependencies ?? {})) {
85
+ const found = lookup.map((directory) => path.join(directory, dependency)).find((candidate) => fs.existsSync(path.join(candidate, "package.json")));
86
+ if (!found) throw new Error(`dependency ${dependency} is not installed for ${root}`);
87
+ const resolved = fs.realpathSync(found);
88
+ if (!inside(resolved)) {
89
+ throw new Error(`dependency ${dependency} resolves outside the install (${resolved}); only a self-contained install (npm i -g) can be pinned`);
90
+ }
91
+ }
92
+ const walk = (directory) => {
93
+ for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
94
+ const absolute = path.join(directory, entry.name);
95
+ if (entry.isDirectory()) walk(absolute);
96
+ else if (entry.isSymbolicLink() && path.isAbsolute(fs.readlinkSync(absolute))) {
97
+ let resolved;
98
+ try {
99
+ resolved = fs.realpathSync(absolute);
100
+ } catch {
101
+ continue;
102
+ }
103
+ if (!inside(resolved)) throw new Error(`link ${path.relative(root, absolute)} leads outside the install (${resolved})`);
104
+ }
105
+ }
106
+ };
107
+ walk(root);
108
+ }
109
+ function readPinMarker(pin) {
110
+ try {
111
+ return JSON.parse(fs.readFileSync(path.join(pin, PIN_MARKER), "utf8"));
112
+ } catch (error) {
113
+ if (error.code === "ENOENT") return void 0;
114
+ return {};
115
+ }
116
+ }
117
+ function assertPinUnchanged(pin, marker) {
118
+ if (typeof marker.contentSha256 !== "string" || marker.contentSha256 !== contentDigest(pin)) {
119
+ throw new Error(`pin ${pin} changed after it was created; remove it, then re-run: mm-harness pin`);
120
+ }
121
+ }
122
+ function sweepStaleStaging(store, now = Date.now()) {
123
+ let names;
124
+ try {
125
+ names = fs.readdirSync(store);
126
+ } catch {
127
+ return;
128
+ }
129
+ for (const name of names) {
130
+ const created = /^\.staging-.+-(\d{13})-[^-]+$/u.exec(name)?.[1];
131
+ if (created && now - Number(created) > STALE_STAGING_MS) {
132
+ fs.rmSync(path.join(store, name), { recursive: true, force: true });
133
+ }
134
+ }
135
+ }
136
+ function pinHarness(options = {}) {
137
+ const sourceAlias = path.resolve(options.sourceRoot ?? runnerDir);
138
+ const source = fs.realpathSync(sourceAlias);
139
+ const store = options.root ?? pinRoot();
140
+ const manifest = readManifest(source);
141
+ if (manifest.name !== PACKAGE_NAME || typeof manifest.version !== "string" || !manifest.version) {
142
+ throw new Error(`not an ${PACKAGE_NAME} package: ${source}`);
143
+ }
144
+ const version = manifest.version;
145
+ const ownExecutable = path.join(source, "bin/mm-harness");
146
+ if (isPin(source)) {
147
+ return { version, source, executable: ownExecutable, pinned: true, created: false, reason: "already-pinned" };
148
+ }
149
+ if (!source.includes(`${path.sep}node_modules${path.sep}`)) {
150
+ return { version, source, executable: ownExecutable, pinned: false, created: false, reason: "source-checkout" };
151
+ }
152
+ const key = `${version}-${contentDigest(source).slice(0, 12)}`;
153
+ const target = path.join(store, key);
154
+ const executable = path.join(target, "bin/mm-harness");
155
+ const existing = readPinMarker(target);
156
+ if (existing) {
157
+ assertPinUnchanged(target, existing);
158
+ return { version, source, executable, pinned: true, created: false };
159
+ }
160
+ assertSelfContained(source);
161
+ fs.mkdirSync(store, { recursive: true });
162
+ sweepStaleStaging(store);
163
+ const staging = fs.mkdtempSync(path.join(store, `.staging-${key}-${Date.now()}-`));
164
+ const copy = path.join(staging, "package");
165
+ try {
166
+ fs.cpSync(source, copy, {
167
+ recursive: true,
168
+ preserveTimestamps: true,
169
+ verbatimSymlinks: true,
170
+ mode: fs.constants.COPYFILE_FICLONE,
171
+ filter: (from) => !RUNNER_STATE.has(path.basename(from))
172
+ });
173
+ relinkIntoCopy(copy, [.../* @__PURE__ */ new Set([source, sourceAlias])]);
174
+ assertSelfContained(copy);
175
+ const check = spawnSync(path.join(copy, "bin/mm-harness"), ["--version"], {
176
+ encoding: "utf8",
177
+ env: { ...process.env, MM_HARNESS_BIN: "", MM_HARNESS_NO_UPDATE_CHECK: "1" },
178
+ timeout: 6e4
179
+ });
180
+ if (check.status !== 0 || check.stdout.trim() !== version) {
181
+ throw new Error(`the copied harness did not run (exit ${check.status}): ${(check.stderr || check.stdout || "").trim()}`);
182
+ }
183
+ const marker = { schemaVersion: 1, key, version, source, contentSha256: contentDigest(copy), createdAt: (/* @__PURE__ */ new Date()).toISOString() };
184
+ fs.writeFileSync(path.join(copy, PIN_MARKER), `${JSON.stringify(marker, null, 2)}
185
+ `);
186
+ try {
187
+ fs.renameSync(copy, target);
188
+ } catch (error) {
189
+ if (!isPin(target)) throw error;
190
+ return { version, source, executable, pinned: true, created: false };
191
+ }
192
+ return { version, source, executable, pinned: true, created: true };
193
+ } finally {
194
+ fs.rmSync(staging, { recursive: true, force: true });
195
+ }
196
+ }
197
+ function handlePin(argv) {
198
+ const json = argv.includes("--json");
199
+ try {
200
+ const result = pinHarness();
201
+ if (json) {
202
+ console.log(JSON.stringify({ schemaVersion: 1, command: "pin", status: "pass", ...result }, null, 2));
203
+ } else {
204
+ console.log(result.executable);
205
+ if (result.reason === "source-checkout") {
206
+ console.error("mm-harness pin: a source checkout is not copied; record it by its working-tree hash.");
207
+ }
208
+ }
209
+ return EXIT.ok;
210
+ } catch (error) {
211
+ const message = error instanceof Error ? error.message : String(error);
212
+ const userAction = "install the harness globally (npm i -g @deeeed/metamask-harness), then re-run: mm-harness pin";
213
+ if (json) {
214
+ console.log(JSON.stringify({ schemaVersion: 1, command: "pin", status: "fail", error: { code: "PIN_FAILED", message, userAction } }, null, 2));
215
+ } else {
216
+ console.error(`\u2717 mm-harness pin: ${message}
217
+ Next: ${userAction}`);
218
+ }
219
+ return EXIT.runtime;
220
+ }
221
+ }
222
+ export {
223
+ handlePin,
224
+ pinHarness,
225
+ pinRoot
226
+ };
@@ -10,7 +10,9 @@ import {
10
10
  import { color, stripAnsi } from "../cli-color.js";
11
11
  import { runnerProvenance } from "../doctor.js";
12
12
  import { detectAdapter } from "../harness.js";
13
- import { harnessExecutable, manifestPath, PREPARE_PROGRESS_ARTIFACT, recipeRuntimeDir } from "../paths.js";
13
+ import { adapterRegistry } from "../adapters/registry.js";
14
+ import { getAdapterSurface } from "../adapters/surface.js";
15
+ import { harnessExecutable, PREPARE_PROGRESS_ARTIFACT, recipeRuntimeDir } from "../paths.js";
14
16
  import { optionFlag, optionString, parseArgs, targetPath, usageError } from "./parse-args.js";
15
17
  import { EXIT } from "./shared.js";
16
18
  import { formatDuration } from "./status-watch.js";
@@ -52,8 +54,8 @@ const STEP_HINT = {
52
54
  launch: "can take minutes on Mobile: Metro bundle and app start",
53
55
  fixtures: "under a minute"
54
56
  };
55
- function stepChain(platform) {
56
- return platform === "core" ? ["doctor", "status", "verify"] : ["doctor", "status", "launch", "fixtures", "verify"];
57
+ function stepChain(surface) {
58
+ return surface.headless ? ["doctor", "status", "verify"] : ["doctor", "status", "launch", "fixtures", "verify"];
57
59
  }
58
60
  function lastRunDurations(recordPath) {
59
61
  let parsed;
@@ -225,36 +227,6 @@ function readDevices(artifactsDir) {
225
227
  const devices = parsed?.devices;
226
228
  return Array.isArray(devices) ? devices : [];
227
229
  }
228
- function readRuntimeContextTarget(target) {
229
- const file = path.join(target, recipeRuntimeDir(), "agentic-runtime.json");
230
- let parsed;
231
- try {
232
- parsed = JSON.parse(fs.readFileSync(file, "utf8"));
233
- } catch {
234
- return {};
235
- }
236
- const record = parsed ?? {};
237
- const str = (key) => typeof record[key] === "string" ? record[key] : void 0;
238
- return { simulator: str("simulator"), simulatorUdid: str("simulatorUdid"), adbSerial: str("adbSerial") };
239
- }
240
- function mobilePlatformFromConfiguredTarget(env, context) {
241
- const ios = Boolean(env.IOS_SIMULATOR || env.SIM_UDID || context.simulator || context.simulatorUdid);
242
- const android = Boolean(
243
- env.ADB_SERIAL || env.ANDROID_SERIAL || env.ANDROID_TARGET_DEVICE_NAME || env.ANDROID_DEVICE || context.adbSerial
244
- );
245
- if (ios === android) return void 0;
246
- return ios ? "ios" : "android";
247
- }
248
- function mobilePlatformFromDevices(devices) {
249
- const selected = devices.filter((device) => device?.selected === true);
250
- const candidates = selected.length > 0 ? selected : devices;
251
- const platforms = [
252
- ...new Set(
253
- candidates.map((device) => device?.platform).filter((value) => value === "ios" || value === "android")
254
- )
255
- ];
256
- return platforms.length === 1 ? platforms[0] : void 0;
257
- }
258
230
  function mobileTargetAmbiguityError() {
259
231
  const choices = [
260
232
  "1. iOS mm-harness prepare --platform mobile --mobile-platform ios",
@@ -300,8 +272,9 @@ function mobilePlatformOption(value) {
300
272
  }
301
273
  function resolvePlatform(requested, target) {
302
274
  const platform = requested ?? detectAdapter(target);
303
- if (platform === "mobile" || platform === "extension" || platform === "core") {
304
- return platform;
275
+ const surface = platform && adapterRegistry.has(platform) ? getAdapterSurface(platform) : void 0;
276
+ if (surface?.prepare) {
277
+ return surface;
305
278
  }
306
279
  throw usageError(
307
280
  `could not detect the platform of ${target}
@@ -328,21 +301,22 @@ async function handlePrepareLocked(argv) {
328
301
  const { options } = parseArgs(argv, "prepare");
329
302
  const json = optionFlag(options, "json");
330
303
  const target = targetPath(options);
331
- const platform = resolvePlatform(optionString(options, "platform"), target);
304
+ const surface = resolvePlatform(optionString(options, "platform"), target);
305
+ const platform = surface.adapter;
332
306
  const artifactsDir = path.resolve(
333
307
  optionString(options, "artifactsDir") ?? path.join(target, recipeRuntimeDir())
334
308
  );
335
309
  const executable = harnessExecutable();
336
310
  const targetArgs = ["--target", target];
337
311
  const clearMetro = optionFlag(options, "clearMetro");
338
- if (clearMetro && platform !== "mobile") {
312
+ if (clearMetro && !surface.prepare.clearMetro) {
339
313
  throw usageError(`--clear-metro is mobile only; this checkout is ${platform}
340
314
  Next: ${USAGE}`);
341
315
  }
342
316
  const remoteFlag = optionString(options, "remoteFlag");
343
317
  if (remoteFlag !== void 0) {
344
- if (platform === "core") {
345
- throw usageError(`--remote-flag needs an app runtime; this checkout is core
318
+ if (surface.headless) {
319
+ throw usageError(`--remote-flag needs an app runtime; this checkout is ${platform}
346
320
  Next: ${USAGE}`);
347
321
  }
348
322
  try {
@@ -357,7 +331,7 @@ async function handlePrepareLocked(argv) {
357
331
  let mobilePlatform = mobilePlatformOption(optionString(options, "mobilePlatform"));
358
332
  const recordPath = path.join(artifactsDir, READINESS_RECORD);
359
333
  const progressPath = path.join(artifactsDir, PREPARE_PROGRESS_ARTIFACT);
360
- const chain = stepChain(platform);
334
+ const chain = stepChain(surface);
361
335
  const view = json ? void 0 : createStepView(chain, stepHints(chain, lastRunDurations(recordPath)));
362
336
  view?.begin();
363
337
  const steps = [];
@@ -406,35 +380,33 @@ async function handlePrepareLocked(argv) {
406
380
  try {
407
381
  await run("doctor", ["doctor", "--fix", "--json", "--adapter", platform, ...targetArgs]);
408
382
  await run("status", ["status", "--json", ...targetArgs]);
409
- if (platform === "mobile" && !mobilePlatform && !failed()) {
410
- mobilePlatform = mobilePlatformFromConfiguredTarget(process.env, readRuntimeContextTarget(target)) ?? mobilePlatformFromDevices(readDevices(artifactsDir));
383
+ const { devicePlatform } = surface.prepare;
384
+ if (devicePlatform && !mobilePlatform && !failed()) {
385
+ mobilePlatform = devicePlatform(target, readDevices(artifactsDir));
411
386
  if (!mobilePlatform) return ambiguousMobileTarget(json);
412
387
  }
413
- if (platform === "core") {
414
- const reason = "core is headless: no app surface to launch or seed";
388
+ if (surface.headless) {
389
+ const reason = `${platform} is headless: no app surface to launch or seed`;
415
390
  addStep(skippedStep("launch", `${executable} launch`, reason));
416
391
  addStep(skippedStep("fixtures", `${executable} fixtures set`, reason));
417
- } else if (platform === "mobile") {
418
- const device = mobilePlatform ? [mobilePlatform] : [];
392
+ } else {
393
+ const device = devicePlatform && mobilePlatform ? [mobilePlatform] : [];
419
394
  await run("launch", ["launch", ...device, ...clearMetro ? ["--clear-metro"] : [], ...remoteFlagArgs, "--verify", "--json", ...targetArgs]);
420
395
  await run("fixtures", [
421
396
  "fixtures",
422
397
  "set",
423
- ...mobilePlatform ? ["--platform", mobilePlatform] : [],
398
+ ...device.flatMap((platformName) => ["--platform", platformName]),
424
399
  ...remoteFlagArgs,
425
400
  "--json",
426
401
  ...targetArgs
427
402
  ]);
428
- } else {
429
- await run("launch", ["launch", ...remoteFlagArgs, "--verify", "--json", ...targetArgs]);
430
- await run("fixtures", ["fixtures", "set", ...remoteFlagArgs, "--json", ...targetArgs]);
431
403
  }
432
404
  await run("verify", ["verify", "--json", "--adapter", platform, ...targetArgs]);
433
405
  } finally {
434
406
  clearInterval(heartbeat);
435
407
  view?.end();
436
408
  }
437
- const provenance = runnerProvenance(manifestPath(platform));
409
+ const provenance = runnerProvenance(surface.actions.manifestPath());
438
410
  const record = {
439
411
  schemaVersion: 1,
440
412
  harness: {
@@ -462,7 +434,5 @@ async function handlePrepareLocked(argv) {
462
434
  }
463
435
  export {
464
436
  handlePrepare,
465
- mobilePlatformFromConfiguredTarget,
466
- mobilePlatformFromDevices,
467
437
  mobileTargetAmbiguityError
468
438
  };