@deeeed/metamask-harness 0.3.9 → 0.5.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 (58) hide show
  1. package/CHANGELOG.md +45 -1
  2. package/dist/adapters/core/surface.js +53 -0
  3. package/dist/adapters/extension/ensure-ready.js +109 -0
  4. package/dist/adapters/extension/extension-id.js +62 -0
  5. package/dist/adapters/extension/runtime-decision.js +305 -0
  6. package/dist/adapters/extension/runtime.js +324 -0
  7. package/dist/adapters/extension/surface.js +69 -0
  8. package/dist/adapters/mobile/deps-markers.js +22 -0
  9. package/dist/adapters/mobile/prepare.js +146 -0
  10. package/dist/adapters/mobile/provision.js +465 -0
  11. package/dist/adapters/mobile/runtime-decision.js +315 -0
  12. package/dist/adapters/mobile/surface.js +54 -0
  13. package/dist/adapters/slot-ports.js +146 -0
  14. package/dist/adapters/surface.js +14 -0
  15. package/dist/adapters.js +485 -0
  16. package/dist/cli-color.js +79 -0
  17. package/dist/cli-commands.js +224 -0
  18. package/dist/cli-version.js +111 -0
  19. package/dist/cli.js +1571 -0
  20. package/dist/commands/debug.js +56 -0
  21. package/dist/commands/fixtures.js +153 -0
  22. package/dist/commands/launch.js +325 -0
  23. package/dist/commands/logs.js +73 -0
  24. package/dist/commands/shared.js +157 -0
  25. package/dist/commands/update.js +243 -0
  26. package/dist/completions-cache.js +53 -0
  27. package/dist/doctor.js +169 -0
  28. package/dist/harness.js +627 -0
  29. package/dist/heal-bounds.js +120 -0
  30. package/dist/index.js +25 -0
  31. package/dist/leaf-invoke.js +19 -0
  32. package/dist/live-adapter-contract.js +240 -0
  33. package/dist/manifest.js +37 -0
  34. package/dist/mm-harness-cli.js +521 -0
  35. package/dist/paths.js +179 -0
  36. package/dist/progress.js +94 -0
  37. package/dist/recording-target.js +133 -0
  38. package/dist/run-recording.js +271 -0
  39. package/dist/runner.js +88 -0
  40. package/dist/types.js +0 -0
  41. package/docs/ADAPTER-SURFACE.md +119 -0
  42. package/docs/CLI-SPEC.md +26 -3
  43. package/docs/UX-PRINCIPLES.md +3 -0
  44. package/package.json +10 -2
  45. package/src/adapters/core/surface.ts +71 -0
  46. package/src/adapters/extension/surface.ts +88 -0
  47. package/src/adapters/mobile/provision.ts +594 -0
  48. package/src/adapters/mobile/surface.ts +71 -0
  49. package/src/adapters/slot-ports.ts +165 -0
  50. package/src/adapters/surface.ts +117 -0
  51. package/src/cli-commands.ts +1 -1
  52. package/src/cli.ts +239 -49
  53. package/src/commands/debug.ts +3 -1
  54. package/src/commands/fixtures.ts +13 -8
  55. package/src/commands/launch.ts +7 -156
  56. package/src/commands/logs.ts +29 -13
  57. package/src/harness.ts +140 -3
  58. package/src/mm-harness-cli.ts +71 -18
@@ -0,0 +1,627 @@
1
+ import { execFileSync, spawnSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { resolveLeafInvoke, shellLeafMissing, missingShellLeafMessage } from "./leaf-invoke.js";
5
+ import { recipeHarnessPath, recipeRuntimeDir, runnerDir } from "./paths.js";
6
+ import { prepareMobile } from "./adapters/mobile/prepare.js";
7
+ const HARNESS_ACTIONS = ["install", "provision", "verify", "cleanup", "live"];
8
+ const ADAPTERS = ["mobile", "extension", "core"];
9
+ function isValidAdapterAction(adapter, action) {
10
+ if (adapter === "core") return action === "install" || action === "provision" || action === "verify" || action === "cleanup";
11
+ return true;
12
+ }
13
+ function harnessUsage() {
14
+ console.error(`mm-harness \u2014 install and validate the MetaMask recipe runtime in a checkout.
15
+
16
+ Run it from inside a MetaMask checkout; the platform (mobile | extension | core)
17
+ is auto-detected from the repo. Pass --platform only to override.
18
+
19
+ Commands (one copy-pasteable example each):
20
+ install Install the recipe harness runtime overlay into the checkout.
21
+ mm-harness install
22
+ provision Install the cached Runway mobile dev client (same path as install --runway).
23
+ mm-harness provision runway ios --adapter mobile
24
+ verify Check the harness/runtime is present and healthy (no app launch).
25
+ mm-harness verify
26
+ cleanup Remove the installed harness overlay and restore the checkout.
27
+ mm-harness cleanup
28
+
29
+ Options:
30
+ --platform <mobile|extension|core> Override auto-detection (alias: --adapter).
31
+ --target <repo> Checkout to operate on (default: current dir).
32
+ --json Machine-readable summary for agents/scripts.
33
+ -- <args> Forward the rest verbatim to the underlying
34
+ script, e.g. -- --platform ios --preflight-mode fast
35
+ (mobile) or -- --cdp-port 6665 (extension).
36
+
37
+ Examples:
38
+ mm-harness install # inside a checkout, auto-detected
39
+ mm-harness verify --platform extension
40
+ mm-harness cleanup --target ../metamask-mobile
41
+
42
+ core supports install, verify, and cleanup (headless; no live).`);
43
+ }
44
+ function failureHint(adapter, action) {
45
+ if (adapter === "core") {
46
+ if (action === "verify") {
47
+ return `Install the core harness first: mm-harness install --platform core`;
48
+ }
49
+ return "Read the error above for the specific cause, then re-run this command.";
50
+ }
51
+ const helper = adapter === "mobile" ? "mm-harness launch ios # or launch android" : "mm-harness launch";
52
+ return `Read the error above for the specific cause. To (re)start the runtime, run: ${helper}`;
53
+ }
54
+ function hasArg(args, needle) {
55
+ return args.some((arg) => arg === needle || arg.startsWith(`${needle}=`));
56
+ }
57
+ function argValue(args, needle) {
58
+ for (let i = 0; i < args.length; i += 1) {
59
+ if (args[i] === needle) return args[i + 1];
60
+ if (args[i].startsWith(`${needle}=`)) return args[i].slice(needle.length + 1);
61
+ }
62
+ return void 0;
63
+ }
64
+ function shellQuote(value) {
65
+ return /^[A-Za-z0-9_./:=@+-]+$/u.test(value) ? value : `'${value.replace(/'/gu, `'\\''`)}'`;
66
+ }
67
+ function isAdapter(value) {
68
+ return value === "mobile" || value === "extension" || value === "core";
69
+ }
70
+ function parseHarnessArgs(args) {
71
+ const forward = [];
72
+ let adapter;
73
+ let json = false;
74
+ let separator = false;
75
+ for (let i = 0; i < args.length; i += 1) {
76
+ const arg = args[i];
77
+ if (separator) {
78
+ forward.push(arg);
79
+ continue;
80
+ }
81
+ if (arg === "--") {
82
+ separator = true;
83
+ continue;
84
+ }
85
+ if (arg === "--json") {
86
+ json = true;
87
+ continue;
88
+ }
89
+ if (arg === "--adapter" || arg === "--platform") {
90
+ const value = args[i + 1];
91
+ if (arg === "--adapter") {
92
+ adapter = value;
93
+ i += 1;
94
+ continue;
95
+ }
96
+ if (adapter === void 0 && isAdapter(value)) {
97
+ adapter = value;
98
+ i += 1;
99
+ continue;
100
+ }
101
+ forward.push(arg);
102
+ continue;
103
+ }
104
+ if (arg.startsWith("--adapter=")) {
105
+ adapter = arg.slice("--adapter=".length);
106
+ continue;
107
+ }
108
+ if (arg.startsWith("--platform=")) {
109
+ const value = arg.slice("--platform=".length);
110
+ if (adapter === void 0 && isAdapter(value)) {
111
+ adapter = value;
112
+ continue;
113
+ }
114
+ forward.push(arg);
115
+ continue;
116
+ }
117
+ forward.push(arg);
118
+ }
119
+ return { adapter, json, forward };
120
+ }
121
+ const ADAPTER_DETECT_NEXT = "cd into a MetaMask checkout or pass --target <path>, or force it with --adapter <mobile|extension|core>";
122
+ function detectAdapter(target) {
123
+ let remote = "";
124
+ try {
125
+ remote = execFileSync("git", ["-C", target, "config", "--get", "remote.origin.url"], {
126
+ encoding: "utf8",
127
+ stdio: ["ignore", "pipe", "ignore"]
128
+ }).trim();
129
+ } catch {
130
+ remote = "";
131
+ }
132
+ if (remote.includes("metamask-extension")) return "extension";
133
+ if (remote.includes("metamask-mobile")) return "mobile";
134
+ const exists = (rel) => fs.existsSync(path.join(target, rel));
135
+ const isDir = (rel) => {
136
+ try {
137
+ return fs.statSync(path.join(target, rel)).isDirectory();
138
+ } catch {
139
+ return false;
140
+ }
141
+ };
142
+ if (exists("development/skills-sync.ts") || isDir("ui") && isDir("app/scripts")) return "extension";
143
+ if (exists("scripts/skills-sync.mts") || isDir("ios") && isDir("android") && isDir("app/core")) return "mobile";
144
+ if (isDir("packages/perps-controller") && exists("yarn.lock")) return "core";
145
+ return void 0;
146
+ }
147
+ function resolveRuntimeContextPath(target) {
148
+ return process.env.RECIPE_RUNTIME_CONTEXT ?? path.join(target, recipeRuntimeDir(), "agentic-runtime.json");
149
+ }
150
+ function readRuntimeContextField(contextPath, field) {
151
+ let data;
152
+ try {
153
+ data = JSON.parse(fs.readFileSync(contextPath, "utf8"));
154
+ } catch {
155
+ return void 0;
156
+ }
157
+ let node = data;
158
+ for (const key of field.split(".")) {
159
+ if (node === void 0 || node === null || typeof node !== "object") return void 0;
160
+ node = node[key];
161
+ }
162
+ if (node === void 0 || node === null || node === "") return void 0;
163
+ return String(node);
164
+ }
165
+ function applyExtensionRuntimeEnv(target, action, args) {
166
+ const contextPath = resolveRuntimeContextPath(target);
167
+ const contextExists = fs.existsSync(contextPath);
168
+ if (contextExists) {
169
+ process.env.RECIPE_RUNTIME_CONTEXT = contextPath;
170
+ if (!process.env.RECIPE_SLOT_ID) {
171
+ const slotId = readRuntimeContextField(contextPath, "slotId");
172
+ if (slotId) process.env.RECIPE_SLOT_ID = slotId;
173
+ }
174
+ if (!process.env.RECIPE_HARNESS_EXTENSION_ID) {
175
+ const extensionId = readRuntimeContextField(contextPath, "extensionId");
176
+ if (extensionId) process.env.RECIPE_HARNESS_EXTENSION_ID = extensionId;
177
+ }
178
+ if (process.env.RECIPE_RUNTIME_START_APPROVED === void 0) {
179
+ const approved = readRuntimeContextField(contextPath, "runtimeStart.approved");
180
+ if (approved === "true" || approved === "True" || approved === "1") process.env.RECIPE_RUNTIME_START_APPROVED = "1";
181
+ else if (approved === "false" || approved === "False" || approved === "0") process.env.RECIPE_RUNTIME_START_APPROVED = "0";
182
+ }
183
+ if (!process.env.RECIPE_RUNTIME_START_CMD) {
184
+ const command = readRuntimeContextField(contextPath, "runtimeStart.command");
185
+ if (command) process.env.RECIPE_RUNTIME_START_CMD = command;
186
+ }
187
+ if (!process.env.RECIPE_RUNTIME_READY_URL) {
188
+ const readyUrl = readRuntimeContextField(contextPath, "runtimeStart.readyUrl");
189
+ if (readyUrl) process.env.RECIPE_RUNTIME_READY_URL = readyUrl;
190
+ }
191
+ }
192
+ let result = [...args];
193
+ if (!hasArg(result, "--cdp-port")) {
194
+ const contextPort = contextExists ? readRuntimeContextField(contextPath, "cdpPort") : void 0;
195
+ const cdpPort = contextPort ?? process.env.RECIPE_CDP_PORT ?? process.env.CDP_PORT;
196
+ if (cdpPort) {
197
+ process.env.RECIPE_CDP_PORT = cdpPort;
198
+ process.env.CDP_PORT = cdpPort;
199
+ result = [...result, "--cdp-port", cdpPort];
200
+ }
201
+ }
202
+ if (action === "live" && !hasArg(result, "--prepare-cmd")) {
203
+ if (process.env.RECIPE_RUNTIME_START_APPROVED === "1" && process.env.RECIPE_RUNTIME_START_CMD) {
204
+ result = [...result, "--prepare-cmd", process.env.RECIPE_RUNTIME_START_CMD];
205
+ }
206
+ }
207
+ return result;
208
+ }
209
+ function resolveEntry(base, candidates, mode) {
210
+ for (const candidate of candidates) {
211
+ const full = path.join(base, candidate);
212
+ try {
213
+ const stat = fs.statSync(full);
214
+ if (!stat.isFile()) continue;
215
+ if (mode === "file" || (stat.mode & 73) !== 0) return full;
216
+ } catch {
217
+ }
218
+ }
219
+ return path.join(base, candidates[0]);
220
+ }
221
+ function installedRunnerSource(target, adapter) {
222
+ const pointer = path.join(recipeHarnessPath(target, adapter), "runner", ".runner-source");
223
+ if (!fs.existsSync(pointer)) return void 0;
224
+ const value = fs.readFileSync(pointer, "utf8").trim();
225
+ if (!value || !fs.existsSync(value)) return void 0;
226
+ return value;
227
+ }
228
+ function isExecutable(file) {
229
+ try {
230
+ const stat = fs.statSync(file);
231
+ return stat.isFile() && (stat.mode & 73) !== 0;
232
+ } catch {
233
+ return false;
234
+ }
235
+ }
236
+ const INJECT_CANDIDATES = {
237
+ mobile: { entry: "adapters/mobile/inject.sh", fallback: "scripts/inject-mobile-harness.sh" },
238
+ extension: { entry: "adapters/extension/inject.mjs", fallback: "scripts/inject-extension-harness.mjs" },
239
+ core: { entry: "adapters/core/inject.sh", fallback: "scripts/inject-core-harness.sh" }
240
+ };
241
+ const CLEANUP_CANDIDATES = {
242
+ mobile: { entry: "adapters/mobile/cleanup.sh", fallback: "scripts/cleanup-mobile-harness.sh" },
243
+ extension: { entry: "adapters/extension/cleanup.mjs", fallback: "scripts/cleanup-extension-harness.mjs" },
244
+ core: { entry: "adapters/core/cleanup.sh", fallback: "scripts/cleanup-core-harness.sh" }
245
+ };
246
+ function resolveHarnessDispatch(adapter, action, target) {
247
+ if (action === "install") {
248
+ const { entry, fallback } = INJECT_CANDIDATES[adapter];
249
+ if (adapter === "extension") {
250
+ return { command: process.execPath, prefixArgs: [resolveEntry(runnerDir, [entry, fallback], "file")] };
251
+ }
252
+ return { command: resolveEntry(runnerDir, [entry, fallback], "file"), prefixArgs: [] };
253
+ }
254
+ if (action === "cleanup") {
255
+ const { entry, fallback } = CLEANUP_CANDIDATES[adapter];
256
+ const base = installedRunnerSource(target, adapter) ?? runnerDir;
257
+ if (adapter === "extension") {
258
+ return { command: process.execPath, prefixArgs: [resolveEntry(base, [entry, fallback], "file")] };
259
+ }
260
+ return { command: resolveEntry(base, [entry, fallback], "file"), prefixArgs: [] };
261
+ }
262
+ if (adapter === "core" && action === "verify") {
263
+ const delegate = path.join(recipeHarnessPath(target, "core"), "runner", "bin", "mm-harness");
264
+ if (!isExecutable(delegate)) {
265
+ return {
266
+ error: `core recipe harness not installed: missing delegate ${delegate}.
267
+ Next: mm-harness install --platform core --target ${target}`
268
+ };
269
+ }
270
+ return { command: delegate, prefixArgs: ["doctor", "--adapter", "core"] };
271
+ }
272
+ const installedScript = path.join(recipeHarnessPath(target, adapter), "scripts", `${action}.sh`);
273
+ if (fs.existsSync(installedScript)) {
274
+ return { command: installedScript, prefixArgs: [] };
275
+ }
276
+ const command = resolveEntry(
277
+ runnerDir,
278
+ [`adapters/${adapter}/${action}.sh`, `scripts/${adapter}/${action}.sh`],
279
+ "file"
280
+ );
281
+ return { command, prefixArgs: [] };
282
+ }
283
+ async function handleMobileLive(target, forwardArgs, json, autoDetected) {
284
+ const platform = argValue(forwardArgs, "--platform") ?? "ios";
285
+ const watcherPortStr = argValue(forwardArgs, "--watcher-port") ?? process.env.WATCHER_PORT;
286
+ const watcherPort = watcherPortStr ? parseInt(watcherPortStr, 10) : void 0;
287
+ const start = Date.now();
288
+ if (!json) {
289
+ const detected = autoDetected ? ", auto-detected" : "";
290
+ console.error(`\u2192 live (mobile${detected}) \u2014 target: ${target}`);
291
+ }
292
+ const prepResult = await prepareMobile(target, { platform, json, watcherPort });
293
+ if (prepResult.status !== 0) {
294
+ const elapsed2 = ((Date.now() - start) / 1e3).toFixed(1);
295
+ if (json) {
296
+ console.log(
297
+ harnessSummary("live", "mobile", target, "fail", prepResult.status, autoDetected, {
298
+ code: "MOBILE_PREPARE_FAILED",
299
+ message: `mobile prepare failed (exit ${prepResult.status})`,
300
+ userAction: failureHint("mobile", "live")
301
+ })
302
+ );
303
+ } else {
304
+ console.error(
305
+ `\u2717 live mobile failed (exit ${prepResult.status}, ${elapsed2}s)
306
+ ${failureHint("mobile", "live")}`
307
+ );
308
+ }
309
+ return prepResult.status;
310
+ }
311
+ const installedVerify = path.join(recipeHarnessPath(target, "mobile"), "scripts", "verify.sh");
312
+ const verifySh = fs.existsSync(installedVerify) ? installedVerify : resolveEntry(runnerDir, ["adapters/mobile/verify.sh", "scripts/mobile/verify.sh"], "file");
313
+ const verifyArgs = hasArg(forwardArgs, "--no-auto-start") ? [...forwardArgs] : ["--no-auto-start", ...forwardArgs];
314
+ if (shellLeafMissing(verifySh)) {
315
+ const message = missingShellLeafMessage(verifySh);
316
+ if (json) {
317
+ console.log(
318
+ harnessSummary("live", "mobile", target, "fail", 1, autoDetected, {
319
+ code: "HARNESS_SPAWN_FAILED",
320
+ message,
321
+ userAction: failureHint("mobile", "live")
322
+ })
323
+ );
324
+ } else {
325
+ console.error(`\u2717 ${message}`);
326
+ }
327
+ return 1;
328
+ }
329
+ const { bin: verifyBin, args: verifySpawnArgs } = resolveLeafInvoke(verifySh, verifyArgs);
330
+ const result = spawnSync(verifyBin, verifySpawnArgs, {
331
+ stdio: json ? ["inherit", 2, "inherit"] : "inherit",
332
+ env: process.env
333
+ });
334
+ const elapsed = ((Date.now() - start) / 1e3).toFixed(1);
335
+ const exitCode = result.status ?? 1;
336
+ if (json) {
337
+ console.log(
338
+ harnessSummary(
339
+ "live",
340
+ "mobile",
341
+ target,
342
+ exitCode === 0 ? "pass" : "fail",
343
+ exitCode,
344
+ autoDetected,
345
+ exitCode === 0 ? void 0 : { code: "MOBILE_VERIFY_FAILED", message: `mobile live verify failed (exit ${exitCode})`, userAction: failureHint("mobile", "live") }
346
+ )
347
+ );
348
+ } else if (exitCode === 0) {
349
+ console.error(`\u2713 live mobile passed (${elapsed}s)`);
350
+ } else {
351
+ console.error(
352
+ `\u2717 live mobile failed (exit ${exitCode}, ${elapsed}s)
353
+ ${failureHint("mobile", "live")}`
354
+ );
355
+ }
356
+ return exitCode;
357
+ }
358
+ async function handleHarness(argv) {
359
+ const action = argv[0];
360
+ if (!action || action === "-h" || action === "--help") {
361
+ harnessUsage();
362
+ return action ? 0 : 2;
363
+ }
364
+ if (!HARNESS_ACTIONS.includes(action)) {
365
+ harnessUsage();
366
+ console.error(`unsupported harness subcommand: ${action}`);
367
+ return 2;
368
+ }
369
+ const harnessAction = action;
370
+ const { adapter: parsedAdapter, json, forward } = parseHarnessArgs(argv.slice(1));
371
+ const rawTarget = argValue(forward, "--target");
372
+ const target = path.resolve(rawTarget ?? process.cwd());
373
+ const adapter = parsedAdapter ?? detectAdapter(target);
374
+ if (!adapter || !isAdapter(adapter)) {
375
+ if (json) {
376
+ const detectError = adapter ? { code: "UNSUPPORTED_PLATFORM", message: `unsupported platform: ${adapter}`, userAction: "pass --adapter <mobile|extension|core> to specify a supported adapter" } : { code: "ADAPTER_DETECTION_FAILED", message: `could not detect the MetaMask repo type for ${target}`, userAction: ADAPTER_DETECT_NEXT };
377
+ console.log(harnessSummary(harnessAction, void 0, target, "fail", 2, false, detectError));
378
+ } else {
379
+ harnessUsage();
380
+ console.error(
381
+ adapter ? `
382
+ \u2717 unsupported platform: ${adapter}` : `
383
+ \u2717 could not detect the MetaMask repo type for ${target}
384
+ Next: ${ADAPTER_DETECT_NEXT}`
385
+ );
386
+ }
387
+ return 2;
388
+ }
389
+ if (!isValidAdapterAction(adapter, harnessAction)) {
390
+ if (json) {
391
+ console.log(
392
+ harnessSummary(harnessAction, adapter, target, "fail", 2, parsedAdapter === void 0, {
393
+ code: "UNSUPPORTED_ACTION",
394
+ message: `${harnessAction} is not supported for ${adapter} (core supports install, verify, cleanup).`,
395
+ userAction: "run mm-harness doctor to see which actions are supported for this adapter"
396
+ })
397
+ );
398
+ } else {
399
+ console.error(
400
+ `\u2717 ${harnessAction} is not supported for ${adapter} (core supports install, verify, cleanup).`
401
+ );
402
+ }
403
+ return 2;
404
+ }
405
+ const autoDetected = parsedAdapter === void 0;
406
+ let forwardArgs = hasArg(forward, "--target") ? [...forward] : ["--target", target, ...forward];
407
+ if (harnessAction === "provision") {
408
+ return handleRunwayInstall(adapter, target, provisionRunwayForward(forward), json, "provision");
409
+ }
410
+ if (harnessAction === "install" && hasArg(forward, "--runway")) {
411
+ return handleRunwayInstall(adapter, target, forward, json, "install");
412
+ }
413
+ if (adapter === "extension" && (harnessAction === "live" || harnessAction === "verify")) {
414
+ forwardArgs = applyExtensionRuntimeEnv(target, harnessAction, forwardArgs);
415
+ }
416
+ if (adapter === "mobile" && harnessAction === "live") {
417
+ return handleMobileLive(target, forwardArgs, json, autoDetected);
418
+ }
419
+ const dispatch = resolveHarnessDispatch(adapter, harnessAction, target);
420
+ if ("error" in dispatch) {
421
+ if (json) {
422
+ console.log(
423
+ harnessSummary(harnessAction, adapter, target, "fail", 1, autoDetected, {
424
+ code: "DISPATCH_UNAVAILABLE",
425
+ message: dispatch.error,
426
+ // The dispatch error embeds "Next: <hint>" — extract it so --json consumers
427
+ // get a clean programmatic escape without parsing the human message.
428
+ userAction: dispatch.error.split("\nNext: ")[1] ?? "run mm-harness install to complete setup, then retry"
429
+ })
430
+ );
431
+ } else {
432
+ console.error(`\u2717 ${harnessAction} ${adapter} failed
433
+ ${dispatch.error.replace(/\n/gu, "\n ")}`);
434
+ }
435
+ return 1;
436
+ }
437
+ if (!json) {
438
+ const detected = autoDetected ? ", auto-detected" : "";
439
+ console.error(`\u2192 ${harnessAction} (${adapter}${detected}) \u2014 target: ${target}`);
440
+ }
441
+ const start = Date.now();
442
+ if (shellLeafMissing(dispatch.command)) {
443
+ const message = missingShellLeafMessage(dispatch.command);
444
+ if (json) {
445
+ console.log(
446
+ harnessSummary(harnessAction, adapter, target, "fail", 1, autoDetected, {
447
+ code: "HARNESS_SPAWN_FAILED",
448
+ message,
449
+ userAction: failureHint(adapter, harnessAction)
450
+ })
451
+ );
452
+ } else {
453
+ console.error(`\u2717 ${message}`);
454
+ }
455
+ return 1;
456
+ }
457
+ const { bin: dispatchBin, args: dispatchArgs } = resolveLeafInvoke(dispatch.command, [
458
+ ...dispatch.prefixArgs,
459
+ ...forwardArgs
460
+ ]);
461
+ const result = spawnSync(dispatchBin, dispatchArgs, {
462
+ // Default: stream child output verbatim (byte-identical to the skill path).
463
+ // --json: route child output to our stderr so stdout carries only the summary.
464
+ stdio: json ? ["inherit", 2, "inherit"] : "inherit",
465
+ env: process.env
466
+ });
467
+ const seconds = ((Date.now() - start) / 1e3).toFixed(1);
468
+ if (result.error) {
469
+ if (json) {
470
+ console.log(
471
+ harnessSummary(harnessAction, adapter, target, "fail", 1, autoDetected, {
472
+ code: "HARNESS_SPAWN_FAILED",
473
+ message: `${harnessAction} ${adapter} could not start: ${result.error.message}`,
474
+ userAction: failureHint(adapter, harnessAction)
475
+ })
476
+ );
477
+ } else {
478
+ console.error(`\u2717 ${harnessAction} ${adapter} could not start: ${result.error.message}
479
+ ${failureHint(adapter, harnessAction)}`);
480
+ }
481
+ return 1;
482
+ }
483
+ const exitCode = result.status ?? 1;
484
+ if (json) {
485
+ console.log(
486
+ harnessSummary(
487
+ harnessAction,
488
+ adapter,
489
+ target,
490
+ exitCode === 0 ? "pass" : "fail",
491
+ exitCode,
492
+ autoDetected,
493
+ exitCode === 0 ? void 0 : { code: "HARNESS_FAILED", message: `${harnessAction} ${adapter} failed (exit ${exitCode})`, userAction: failureHint(adapter, harnessAction) }
494
+ )
495
+ );
496
+ } else if (exitCode === 0) {
497
+ console.error(`\u2713 ${harnessAction} ${adapter} passed (${seconds}s)`);
498
+ } else {
499
+ console.error(`\u2717 ${harnessAction} ${adapter} failed (exit ${exitCode}, ${seconds}s)
500
+ ${failureHint(adapter, harnessAction)}`);
501
+ }
502
+ return exitCode;
503
+ }
504
+ async function handleRunwayInstall(adapter, target, forward, json, action) {
505
+ const { getAdapterSurface } = await import("./adapters/surface.js");
506
+ const rerunCommand = action === "provision" ? runwayProvisionRerunCommand(adapter, target, forward, json) : runwayInstallRerunCommand(adapter, target, forward, json);
507
+ const surface = getAdapterSurface(adapter);
508
+ const runtimeDir = argValue(forward, "--runtime-dir");
509
+ const watcherPort = argValue(forward, "--watcher-port");
510
+ const result = await surface.runwayProvision.run(target, {
511
+ json,
512
+ platform: argValue(forward, "--platform") ?? "ios",
513
+ branch: argValue(forward, "--branch"),
514
+ defaultBranch: argValue(forward, "--default-branch"),
515
+ run: argValue(forward, "--run"),
516
+ cacheRoot: argValue(forward, "--cache-root"),
517
+ simulator: argValue(forward, "--simulator") ?? argValue(forward, "--device"),
518
+ runtime: argValue(forward, "--runtime"),
519
+ deviceType: argValue(forward, "--device-type"),
520
+ slot: argValue(forward, "--slot"),
521
+ watcherPort,
522
+ runtimeDir,
523
+ force: hasArg(forward, "--force"),
524
+ resolveOnly: hasArg(forward, "--resolve-only"),
525
+ rerunCommand
526
+ });
527
+ if (json) {
528
+ console.log(JSON.stringify(result, null, 2));
529
+ } else if (result.status === "pass") {
530
+ const cache = typeof result.cache === "object" && result.cache ? result.cache : void 0;
531
+ const simulator = typeof result.simulator === "object" && result.simulator ? result.simulator : void 0;
532
+ const artifact = typeof result.artifact === "object" && result.artifact ? result.artifact : void 0;
533
+ if (result.resolveOnly) {
534
+ console.error(`\u2713 resolved Runway app for ${adapter} ${result.platform ?? ""} run=${artifact?.runId ?? "unknown"} revision=${artifact?.revision ?? "unknown"} artifact=${artifact?.artifactName ?? "unknown"}`);
535
+ } else {
536
+ const action2 = result.skipped ? "already provisioned" : "installed Runway app";
537
+ console.error(`\u2713 ${action2} for ${adapter} ${result.platform ?? ""} simulator=${simulator?.name ?? "unknown"} cache=${cache?.status ?? "skip"}`);
538
+ }
539
+ } else {
540
+ const label = action === "provision" ? "provision" : "install --runway";
541
+ console.error(`\u2717 mm-harness ${label}: ${result.error?.message ?? "runway install failed"}
542
+ Next: ${result.error?.userAction ?? rerunCommand}`);
543
+ }
544
+ return result.exitCode;
545
+ }
546
+ function provisionRunwayForward(forward) {
547
+ const normalized = [];
548
+ let index = 0;
549
+ if (forward[index] === "runway") index += 1;
550
+ if (forward[index] && !forward[index].startsWith("-")) {
551
+ if (!hasArg(forward, "--platform")) normalized.push("--platform", forward[index]);
552
+ index += 1;
553
+ }
554
+ return [...normalized, ...forward.slice(index)];
555
+ }
556
+ function runwayInstallRerunCommand(adapter, target, forward, json) {
557
+ const parts = ["mm-harness", "install", "--runway", "--adapter", adapter, "--target", shellQuote(target)];
558
+ const valueFlags = [
559
+ "--platform",
560
+ "--branch",
561
+ "--default-branch",
562
+ "--run",
563
+ "--cache-root",
564
+ "--simulator",
565
+ "--device",
566
+ "--slot",
567
+ "--watcher-port",
568
+ "--runtime-dir",
569
+ "--runtime",
570
+ "--device-type"
571
+ ];
572
+ for (const flag of valueFlags) {
573
+ const value = argValue(forward, flag);
574
+ if (value) parts.push(flag, shellQuote(value));
575
+ }
576
+ if (hasArg(forward, "--force")) parts.push("--force");
577
+ if (hasArg(forward, "--resolve-only")) parts.push("--resolve-only");
578
+ if (json) parts.push("--json");
579
+ return parts.join(" ");
580
+ }
581
+ function runwayProvisionRerunCommand(adapter, target, forward, json) {
582
+ const parts = ["mm-harness", "provision", "runway", shellQuote(argValue(forward, "--platform") ?? "ios"), "--adapter", adapter, "--target", shellQuote(target)];
583
+ const valueFlags = [
584
+ "--branch",
585
+ "--default-branch",
586
+ "--run",
587
+ "--cache-root",
588
+ "--simulator",
589
+ "--device",
590
+ "--slot",
591
+ "--watcher-port",
592
+ "--runtime-dir",
593
+ "--runtime",
594
+ "--device-type"
595
+ ];
596
+ for (const flag of valueFlags) {
597
+ const value = argValue(forward, flag);
598
+ if (value) parts.push(flag, shellQuote(value));
599
+ }
600
+ if (hasArg(forward, "--force")) parts.push("--force");
601
+ if (hasArg(forward, "--resolve-only")) parts.push("--resolve-only");
602
+ if (json) parts.push("--json");
603
+ return parts.join(" ");
604
+ }
605
+ function harnessSummary(action, adapter, target, status, exitCode, autoDetected, error) {
606
+ return JSON.stringify({
607
+ schemaVersion: 1,
608
+ command: "harness",
609
+ action,
610
+ adapter: adapter ?? null,
611
+ target,
612
+ autoDetected,
613
+ status,
614
+ exitCode,
615
+ // Error contract: every --json failure carries a stable machine code + human
616
+ // message (CLI-SPEC.md §5.1). userAction is included when present so callers
617
+ // can surface the reachable escape without parsing the human message.
618
+ ...status === "fail" && error ? { error } : {}
619
+ });
620
+ }
621
+ export {
622
+ ADAPTER_DETECT_NEXT,
623
+ detectAdapter,
624
+ handleHarness,
625
+ readRuntimeContextField,
626
+ resolveRuntimeContextPath
627
+ };