@deeeed/metamask-harness 0.5.1 → 0.6.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 (89) hide show
  1. package/CHANGELOG.md +37 -20
  2. package/adapters/core/inject.sh +1 -0
  3. package/adapters/extension/cleanup.mjs +1 -1
  4. package/adapters/extension/inject.mjs +2 -1
  5. package/adapters/extension/launch-browser.cjs +18 -4
  6. package/adapters/extension/live.sh +14 -2
  7. package/adapters/extension/sidepanel-toggle.sh +33 -12
  8. package/adapters/extension/wallet-fixture-state.cjs +38 -24
  9. package/adapters/manifest.json +13 -0
  10. package/adapters/mobile/inject.sh +1 -0
  11. package/adapters/mobile/stop-metro.sh +20 -0
  12. package/adapters/shared/cli-ux.sh +20 -2
  13. package/adapters/shared/ensure-runner-deps.sh +30 -0
  14. package/adapters/shared/reap-checkout-metros.sh +53 -0
  15. package/adapters/shared/recipe-harness-root.mjs +23 -0
  16. package/adapters/shared/resolve-farmslot-ports-core.mjs +15 -6
  17. package/bin/mm-harness +45 -4
  18. package/dist/adapters/extension/runtime.js +3 -1
  19. package/dist/adapters/mobile/provision.js +34 -1
  20. package/dist/adapters/slot-ports.js +3 -8
  21. package/dist/cli.js +32 -1463
  22. package/dist/commands/call.js +183 -0
  23. package/dist/commands/completion-candidates.js +58 -0
  24. package/dist/commands/doctor.js +101 -0
  25. package/dist/commands/ensure-ready.js +24 -0
  26. package/dist/commands/flows.js +62 -0
  27. package/dist/commands/launch/extension.js +40 -0
  28. package/dist/commands/{launch.js → launch/index.js} +15 -47
  29. package/dist/commands/launch/mobile.js +10 -0
  30. package/dist/commands/manifest.js +72 -0
  31. package/dist/commands/parse-args.js +189 -0
  32. package/dist/commands/provision.js +136 -0
  33. package/dist/commands/resolve-extension.js +23 -0
  34. package/dist/commands/run-engine.js +341 -0
  35. package/dist/commands/run.js +217 -0
  36. package/dist/commands/runtime-decision.js +58 -0
  37. package/dist/commands/runtime-health.js +25 -0
  38. package/dist/commands/runtime-launch.js +139 -0
  39. package/dist/commands/self-test.js +52 -0
  40. package/dist/commands/stop.js +52 -0
  41. package/dist/harness.js +8 -48
  42. package/dist/mm-harness-cli.js +13 -8
  43. package/docs/CLI-SPEC.md +1 -1
  44. package/docs/CODE-MAP.md +62 -0
  45. package/library/README.md +14 -0
  46. package/library/actions/extension/platform/cdp.mjs +1 -1
  47. package/library/actions/extension/wallet/ensure_unlocked.mjs +6 -0
  48. package/library/actions/mobile/wallet/ensure_unlocked.mjs +13 -1
  49. package/package.json +6 -7
  50. package/src/adapters/core/surface.ts +0 -71
  51. package/src/adapters/extension/ensure-ready.ts +0 -185
  52. package/src/adapters/extension/extension-id.ts +0 -107
  53. package/src/adapters/extension/runtime-decision.ts +0 -445
  54. package/src/adapters/extension/runtime.ts +0 -407
  55. package/src/adapters/extension/surface.ts +0 -88
  56. package/src/adapters/mobile/deps-markers.ts +0 -21
  57. package/src/adapters/mobile/prepare.ts +0 -246
  58. package/src/adapters/mobile/provision.ts +0 -594
  59. package/src/adapters/mobile/runtime-decision.ts +0 -466
  60. package/src/adapters/mobile/surface.ts +0 -71
  61. package/src/adapters/resolve-farmslot-ports.ts +0 -13
  62. package/src/adapters/slot-ports.ts +0 -158
  63. package/src/adapters/surface.ts +0 -117
  64. package/src/adapters.ts +0 -601
  65. package/src/cli-color.ts +0 -92
  66. package/src/cli-commands.ts +0 -250
  67. package/src/cli-version.ts +0 -141
  68. package/src/cli.ts +0 -2091
  69. package/src/commands/debug.ts +0 -65
  70. package/src/commands/fixtures.ts +0 -198
  71. package/src/commands/launch.ts +0 -470
  72. package/src/commands/logs.ts +0 -99
  73. package/src/commands/shared.ts +0 -235
  74. package/src/commands/update.ts +0 -316
  75. package/src/completions-cache.ts +0 -86
  76. package/src/doctor.ts +0 -215
  77. package/src/harness.ts +0 -797
  78. package/src/heal-bounds.ts +0 -198
  79. package/src/index.ts +0 -15
  80. package/src/leaf-invoke.ts +0 -28
  81. package/src/live-adapter-contract.ts +0 -274
  82. package/src/manifest.ts +0 -47
  83. package/src/mm-harness-cli.ts +0 -655
  84. package/src/paths.ts +0 -198
  85. package/src/progress.ts +0 -117
  86. package/src/recording-target.ts +0 -147
  87. package/src/run-recording.ts +0 -329
  88. package/src/runner.ts +0 -108
  89. package/src/types.ts +0 -57
@@ -0,0 +1,189 @@
1
+ import path from "node:path";
2
+ import { detectAdapter } from "../harness.js";
3
+ import { assertAdapter, manifestPath } from "../paths.js";
4
+ import { ADAPTER_DETECT_NEXT } from "./shared.js";
5
+ import { EXIT } from "./shared.js";
6
+ class CliError extends Error {
7
+ exitCode;
8
+ constructor(message, exitCode) {
9
+ super(message);
10
+ this.name = "CliError";
11
+ this.exitCode = exitCode;
12
+ }
13
+ }
14
+ function usageError(message) {
15
+ return new CliError(message, EXIT.usage);
16
+ }
17
+ function parseArgs(argv, command) {
18
+ const positional = [];
19
+ const options = {};
20
+ const booleanOptions = /* @__PURE__ */ new Set([
21
+ "json",
22
+ "launchExistingDist",
23
+ "startWatch",
24
+ "record",
25
+ "plan",
26
+ "raw",
27
+ "fix",
28
+ "force",
29
+ "resolveOnly",
30
+ "help"
31
+ ]);
32
+ for (let i = 0; i < argv.length; i += 1) {
33
+ const arg = argv[i];
34
+ if (!arg.startsWith("--")) {
35
+ positional.push(arg);
36
+ continue;
37
+ }
38
+ const body = arg.slice(2);
39
+ const equalsIndex = body.indexOf("=");
40
+ const rawKey = equalsIndex === -1 ? body : body.slice(0, equalsIndex);
41
+ const inlineValue = equalsIndex === -1 ? void 0 : body.slice(equalsIndex + 1);
42
+ const key = normalizeOptionKey(rawKey);
43
+ if (key === "recordVideo") {
44
+ options.recordVideo = parseRecordVideoMode(inlineValue);
45
+ continue;
46
+ }
47
+ if (key === "recordBaseline") {
48
+ options.record = true;
49
+ continue;
50
+ }
51
+ if (key === "record") {
52
+ if (command === "runtime-decision") {
53
+ options.record = true;
54
+ continue;
55
+ }
56
+ options.recordVideo = "full-run";
57
+ continue;
58
+ }
59
+ if (booleanOptions.has(key)) {
60
+ options[key] = true;
61
+ continue;
62
+ }
63
+ if (inlineValue !== void 0) {
64
+ options[key] = inlineValue;
65
+ continue;
66
+ }
67
+ if (i + 1 >= argv.length) throw usageError(`Missing value for ${arg}`);
68
+ options[key] = argv[i + 1];
69
+ i += 1;
70
+ }
71
+ return { positional, options, rawArgv: [...argv] };
72
+ }
73
+ function parseRecordVideoMode(value) {
74
+ if (value === void 0 || value === "" || value === "true") return "full-run";
75
+ if (value === "off" || value === "false") return false;
76
+ if (value === "proof-window" || value === "proof_window") {
77
+ throw usageError(
78
+ "--record-video=proof-window is not supported yet; use --record-video=full-run."
79
+ );
80
+ }
81
+ if (value !== "full-run") {
82
+ throw usageError("--record-video must be full-run or off.");
83
+ }
84
+ return "full-run";
85
+ }
86
+ function normalizeOptionKey(key) {
87
+ return key.replace(/-([a-z])/gu, (_, character) => character.toUpperCase());
88
+ }
89
+ function optionString(options, key) {
90
+ const value = options[key];
91
+ if (value === void 0) return void 0;
92
+ if (typeof value !== "string") throw usageError(`--${key} requires a value.`);
93
+ return value;
94
+ }
95
+ function optionFlag(options, key) {
96
+ const value = options[key];
97
+ return value === true;
98
+ }
99
+ function applyRuntimeDirOption(options) {
100
+ const runtimeDir = optionString(options, "runtimeDir");
101
+ if (runtimeDir) process.env.RECIPE_RUNTIME_DIR = runtimeDir;
102
+ }
103
+ function applyWatcherPortOption(options) {
104
+ const watcherPort = optionString(options, "watcherPort");
105
+ if (!watcherPort) return;
106
+ process.env.WATCHER_PORT = watcherPort;
107
+ process.env.RECIPE_WATCHER_PORT = watcherPort;
108
+ process.env.METRO_PORT = watcherPort;
109
+ }
110
+ function requiredOption(options, key, message) {
111
+ const value = optionString(options, key);
112
+ if (!value) throw usageError(message);
113
+ return value;
114
+ }
115
+ function adapterOption(options) {
116
+ const adapter = optionString(options, "adapter");
117
+ try {
118
+ assertAdapter(adapter);
119
+ } catch (error) {
120
+ throw usageError(error instanceof Error ? error.message : String(error));
121
+ }
122
+ return adapter;
123
+ }
124
+ function targetPath(options) {
125
+ return path.resolve(optionString(options, "target") ?? optionString(options, "projectRoot") ?? process.cwd());
126
+ }
127
+ function actionManifestPathOption(options, adapter) {
128
+ const configured = optionString(options, "actionManifest");
129
+ return configured ? path.resolve(configured) : manifestPath(adapter);
130
+ }
131
+ function parsePort(value, errorMessage) {
132
+ const port = Number(value);
133
+ if (!Number.isInteger(port) || port <= 0) throw usageError(errorMessage);
134
+ return port;
135
+ }
136
+ function runtimeOptionsFromCli(options) {
137
+ const recordVideo = options.recordVideo;
138
+ return {
139
+ cdpPort: optionString(options, "cdpPort"),
140
+ watcherPort: optionString(options, "watcherPort") ?? optionString(options, "metroPort"),
141
+ launchExistingDist: optionFlag(options, "launchExistingDist"),
142
+ slot: optionString(options, "slot"),
143
+ validationRuntimeDir: optionString(options, "validationRuntimeDir"),
144
+ recordVideo: recordVideo === "full-run" ? "full-run" : false
145
+ };
146
+ }
147
+ function resolveAdapter(options) {
148
+ const target = targetPath(options);
149
+ const explicit = optionString(options, "adapter") ?? optionString(options, "platform");
150
+ const adapter = explicit ?? detectAdapter(target);
151
+ if (!adapter) {
152
+ throw usageError(`could not detect the MetaMask repo type for ${target}
153
+ Next: ${ADAPTER_DETECT_NEXT}`);
154
+ }
155
+ try {
156
+ assertAdapter(adapter);
157
+ } catch (error) {
158
+ throw usageError(error instanceof Error ? error.message : String(error));
159
+ }
160
+ return { adapter, target };
161
+ }
162
+ function shellQuote(value) {
163
+ return `'${value.replace(/'/gu, `'\\''`)}'`;
164
+ }
165
+ function shellQuoteArg(value) {
166
+ return /^[A-Za-z0-9_./:=@+-]+$/u.test(value) ? value : shellQuote(value);
167
+ }
168
+ function isRecord(value) {
169
+ return typeof value === "object" && value !== null && !Array.isArray(value);
170
+ }
171
+ export {
172
+ CliError,
173
+ actionManifestPathOption,
174
+ adapterOption,
175
+ applyRuntimeDirOption,
176
+ applyWatcherPortOption,
177
+ isRecord,
178
+ optionFlag,
179
+ optionString,
180
+ parseArgs,
181
+ parsePort,
182
+ requiredOption,
183
+ resolveAdapter,
184
+ runtimeOptionsFromCli,
185
+ shellQuote,
186
+ shellQuoteArg,
187
+ targetPath,
188
+ usageError
189
+ };
@@ -0,0 +1,136 @@
1
+ import { detectAdapter } from "../harness.js";
2
+ import { assertAdapter } from "../paths.js";
3
+ import { getAdapterSurface } from "../adapters/surface.js";
4
+ import { ADAPTER_DETECT_NEXT, usageOut } from "./shared.js";
5
+ import {
6
+ applyWatcherPortOption,
7
+ optionFlag,
8
+ optionString,
9
+ shellQuote,
10
+ shellQuoteArg,
11
+ targetPath
12
+ } from "./parse-args.js";
13
+ async function handleProvision({ positional, options, rawArgv }) {
14
+ applyWatcherPortOption(options);
15
+ const json = optionFlag(options, "json");
16
+ const target = targetPath(options);
17
+ const adapter = optionString(options, "adapter") ?? detectAdapter(target);
18
+ if (!adapter) {
19
+ return usageOut(json, "provision", `could not detect the MetaMask repo type for ${target}`, ADAPTER_DETECT_NEXT);
20
+ }
21
+ try {
22
+ assertAdapter(adapter);
23
+ } catch (error) {
24
+ return usageOut(json, "provision", error instanceof Error ? error.message : String(error), ADAPTER_DETECT_NEXT);
25
+ }
26
+ const surface = getAdapterSurface(adapter);
27
+ const rerunCommand = provisionRerunCommand(rawArgv, options, adapter, target);
28
+ const result = await surface.runwayProvision.run(target, {
29
+ json,
30
+ platform: optionString(options, "platform") ?? optionString(options, "devicePlatform") ?? (positional[0] === "runway" ? positional[1] : positional[0]) ?? "ios",
31
+ branch: optionString(options, "branch"),
32
+ defaultBranch: optionString(options, "defaultBranch"),
33
+ run: optionString(options, "run"),
34
+ cacheRoot: optionString(options, "cacheRoot"),
35
+ simulator: optionString(options, "simulator") ?? optionString(options, "device"),
36
+ runtime: optionString(options, "runtime"),
37
+ deviceType: optionString(options, "deviceType"),
38
+ slot: optionString(options, "slot"),
39
+ watcherPort: optionString(options, "watcherPort"),
40
+ runtimeDir: optionString(options, "runtimeDir"),
41
+ force: optionFlag(options, "force"),
42
+ resolveOnly: optionFlag(options, "resolveOnly"),
43
+ rerunCommand
44
+ });
45
+ if (json) {
46
+ console.log(JSON.stringify(result, null, 2));
47
+ } else if (result.status === "pass") {
48
+ const cache = typeof result.cache === "object" && result.cache ? result.cache : void 0;
49
+ const simulator = typeof result.simulator === "object" && result.simulator ? result.simulator : void 0;
50
+ const artifact = typeof result.artifact === "object" && result.artifact ? result.artifact : void 0;
51
+ if (result.resolveOnly) {
52
+ console.error(`\u2713 resolved ${adapter} ${result.platform ?? ""} run=${artifact?.runId ?? "unknown"} revision=${artifact?.revision ?? "unknown"} artifact=${artifact?.artifactName ?? "unknown"}`);
53
+ } else {
54
+ const action = result.skipped ? "already provisioned" : "provisioned";
55
+ console.error(`\u2713 ${action} ${adapter} ${result.platform ?? ""} simulator=${simulator?.name ?? "unknown"} cache=${cache?.status ?? "unknown"}`);
56
+ }
57
+ } else {
58
+ console.error(`\u2717 mm-harness provision: ${result.error?.message ?? "provision failed"}
59
+ Next: ${result.error?.userAction ?? rerunCommand}`);
60
+ }
61
+ return result.exitCode;
62
+ }
63
+ function provisionRerunCommand(rawArgv, options, adapter, target) {
64
+ const parts = ["mm-harness", "provision"];
65
+ for (const positional of provisionPositionals(rawArgv)) parts.push(shellQuoteArg(positional));
66
+ parts.push("--adapter", adapter, "--target", shellQuote(target));
67
+ const aliases = [
68
+ ["platform", ["--platform", "--device-platform"]],
69
+ ["branch", ["--branch"]],
70
+ ["defaultBranch", ["--default-branch"]],
71
+ ["run", ["--run"]],
72
+ ["cacheRoot", ["--cache-root"]],
73
+ ["simulator", ["--simulator", "--device"]],
74
+ ["runtime", ["--runtime"]],
75
+ ["deviceType", ["--device-type"]],
76
+ ["slot", ["--slot"]],
77
+ ["watcherPort", ["--watcher-port"]],
78
+ ["runtimeDir", ["--runtime-dir"]]
79
+ ];
80
+ for (const [key, flags] of aliases) {
81
+ const found = findRawOption(rawArgv, flags);
82
+ const value = found?.value ?? optionString(options, key);
83
+ if (value) parts.push(found?.flag ?? flags[0], shellQuoteArg(value));
84
+ }
85
+ if (optionFlag(options, "force")) parts.push("--force");
86
+ if (optionFlag(options, "resolveOnly")) parts.push("--resolve-only");
87
+ if (optionFlag(options, "json")) parts.push("--json");
88
+ return parts.join(" ");
89
+ }
90
+ function provisionPositionals(rawArgv) {
91
+ const positionals = [];
92
+ const valueFlags = /* @__PURE__ */ new Set([
93
+ "--adapter",
94
+ "--target",
95
+ "--project-root",
96
+ "--platform",
97
+ "--device-platform",
98
+ "--branch",
99
+ "--default-branch",
100
+ "--run",
101
+ "--cache-root",
102
+ "--simulator",
103
+ "--device",
104
+ "--runtime",
105
+ "--device-type",
106
+ "--slot",
107
+ "--watcher-port",
108
+ "--runtime-dir"
109
+ ]);
110
+ for (let i = 0; i < rawArgv.length; i += 1) {
111
+ const arg = rawArgv[i];
112
+ if (!arg.startsWith("--")) {
113
+ positionals.push(arg);
114
+ continue;
115
+ }
116
+ const key = arg.includes("=") ? arg.slice(0, arg.indexOf("=")) : arg;
117
+ if (!arg.includes("=") && valueFlags.has(key)) i += 1;
118
+ }
119
+ return positionals;
120
+ }
121
+ function findRawOption(rawArgv, flags) {
122
+ for (let i = 0; i < rawArgv.length; i += 1) {
123
+ const arg = rawArgv[i];
124
+ for (const flag of flags) {
125
+ if (arg === flag) {
126
+ const value = rawArgv[i + 1];
127
+ return value && !value.startsWith("--") ? { flag, value } : void 0;
128
+ }
129
+ if (arg.startsWith(`${flag}=`)) return { flag, value: arg.slice(flag.length + 1) };
130
+ }
131
+ }
132
+ return void 0;
133
+ }
134
+ export {
135
+ handleProvision
136
+ };
@@ -0,0 +1,23 @@
1
+ import { resolveExtensionId } from "../adapters/extension/extension-id.js";
2
+ import {
3
+ adapterOption,
4
+ optionFlag,
5
+ optionString,
6
+ parsePort,
7
+ targetPath
8
+ } from "./parse-args.js";
9
+ async function handleResolveExtension({ options }) {
10
+ const adapter = adapterOption(options);
11
+ if (adapter !== "extension") throw new Error("resolve-extension currently applies to the extension adapter.");
12
+ const target = targetPath(options);
13
+ const cdpPortRaw = optionString(options, "cdpPort") ?? process.env.CDP_PORT ?? process.env.RECIPE_CDP_PORT;
14
+ const cdpPort = cdpPortRaw === void 0 ? void 0 : parsePort(cdpPortRaw, "resolve-extension --cdp-port must be a port.");
15
+ const result = await resolveExtensionId(target, { cdpPort });
16
+ if (optionFlag(options, "json")) console.log(JSON.stringify(result, null, 2));
17
+ else if (result.extensionId) console.log(result.extensionId);
18
+ else console.error("Could not resolve a MetaMask extension id (no dist key and no single CDP extension).");
19
+ return result.extensionId ? 0 : 1;
20
+ }
21
+ export {
22
+ handleResolveExtension
23
+ };
@@ -0,0 +1,341 @@
1
+ import fs from "node:fs";
2
+ import { mkdtemp } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import {
6
+ ensureOverlay,
7
+ newHealState,
8
+ parseHeal,
9
+ recipeRunning,
10
+ checkHealBounds
11
+ } from "../heal-bounds.js";
12
+ import { loadActionManifest, validateManifest } from "../manifest.js";
13
+ import { getAdapterSurface } from "../adapters/surface.js";
14
+ import {
15
+ importRecipeHarness,
16
+ importRecipeProtocol,
17
+ runnerDir
18
+ } from "../paths.js";
19
+ import { captureHelperSupportsRecordSessionSnapshots } from "../recording-target.js";
20
+ import { startRecipeRecording, stopRecipeRecording } from "../run-recording.js";
21
+ import { EXIT } from "./shared.js";
22
+ import {
23
+ actionManifestPathOption,
24
+ optionString,
25
+ isRecord,
26
+ usageError
27
+ } from "./parse-args.js";
28
+ async function runRecipe(adapter, recipe, artifactsDir, projectRoot, actionManifestPath, runtimeOptions = {}) {
29
+ const previousCdpPort = process.env.CDP_PORT;
30
+ const previousRecipeCdpPort = process.env.RECIPE_CDP_PORT;
31
+ const previousWatcherPort = process.env.WATCHER_PORT;
32
+ const previousMetroPort = process.env.METRO_PORT;
33
+ const previousExtensionAutolaunch = process.env.METAMASK_RECIPE_EXTENSION_AUTOLAUNCH;
34
+ getAdapterSurface(adapter).resolveSlotPorts(projectRoot);
35
+ if (runtimeOptions.cdpPort) {
36
+ process.env.CDP_PORT = runtimeOptions.cdpPort;
37
+ process.env.RECIPE_CDP_PORT = runtimeOptions.cdpPort;
38
+ }
39
+ if (runtimeOptions.watcherPort) {
40
+ process.env.WATCHER_PORT = runtimeOptions.watcherPort;
41
+ process.env.METRO_PORT = runtimeOptions.watcherPort;
42
+ }
43
+ if (runtimeOptions.launchExistingDist) {
44
+ process.env.METAMASK_RECIPE_EXTENSION_AUTOLAUNCH = "1";
45
+ }
46
+ try {
47
+ await prepareRuntimeIfNeeded(adapter, projectRoot, runtimeOptions);
48
+ const absoluteArtifactsDir = path.resolve(artifactsDir);
49
+ const recordVideo = runtimeOptions.recordVideo ?? false;
50
+ const useFramedExtensionRecording = adapter === "extension" && recordVideo === "full-run" && captureHelperSupportsRecordSessionSnapshots(projectRoot);
51
+ const recording = useFramedExtensionRecording ? await startRecipeRecording(adapter, projectRoot, absoluteArtifactsDir, {
52
+ record: true,
53
+ cdpPort: runtimeOptions.cdpPort
54
+ }) : void 0;
55
+ try {
56
+ const manifest = loadActionManifest(adapter, actionManifestPath);
57
+ await validateManifest(manifest);
58
+ const { createMetaMaskRunner } = await import("../runner.js");
59
+ const runner = await createMetaMaskRunner(adapter, manifest, {
60
+ quietStdout: runtimeOptions.stdoutIsMachineContract === true
61
+ });
62
+ const runRequest = {
63
+ recipePath: path.resolve(recipe),
64
+ artifactsDir: absoluteArtifactsDir,
65
+ projectRoot,
66
+ env: recipeRunEnv(adapter, runtimeOptions),
67
+ recordVideo: useFramedExtensionRecording ? false : recordVideo,
68
+ ...runtimeOptions.librarySources ? { librarySources: runtimeOptions.librarySources } : {}
69
+ };
70
+ const result = await runner.run(runRequest);
71
+ await stopRecipeRecording(recording, result);
72
+ return result;
73
+ } finally {
74
+ await stopRecipeRecording(recording);
75
+ }
76
+ } finally {
77
+ restoreEnv("CDP_PORT", previousCdpPort);
78
+ restoreEnv("RECIPE_CDP_PORT", previousRecipeCdpPort);
79
+ restoreEnv("WATCHER_PORT", previousWatcherPort);
80
+ restoreEnv("METRO_PORT", previousMetroPort);
81
+ restoreEnv("METAMASK_RECIPE_EXTENSION_AUTOLAUNCH", previousExtensionAutolaunch);
82
+ }
83
+ }
84
+ function recipeRunEnv(adapter, runtimeOptions = {}) {
85
+ const base = {
86
+ CDP_PORT: runtimeOptions.cdpPort ?? process.env.CDP_PORT,
87
+ RECIPE_CDP_PORT: runtimeOptions.cdpPort ?? process.env.RECIPE_CDP_PORT,
88
+ FARMSLOT_SLOT_ID: runtimeOptions.slot ?? process.env.FARMSLOT_SLOT_ID,
89
+ SLOT_ID: runtimeOptions.slot ?? process.env.SLOT_ID,
90
+ PLATFORM: process.env.PLATFORM
91
+ };
92
+ if (adapter !== "mobile") return base;
93
+ return {
94
+ ...base,
95
+ WATCHER_PORT: process.env.WATCHER_PORT ?? base.CDP_PORT ?? base.RECIPE_CDP_PORT,
96
+ METRO_PORT: process.env.METRO_PORT ?? process.env.WATCHER_PORT ?? base.CDP_PORT ?? base.RECIPE_CDP_PORT,
97
+ IOS_SIMULATOR: process.env.IOS_SIMULATOR,
98
+ ANDROID_DEVICE: process.env.ANDROID_DEVICE,
99
+ ADB_SERIAL: process.env.ADB_SERIAL,
100
+ ANDROID_SERIAL: process.env.ANDROID_SERIAL
101
+ };
102
+ }
103
+ async function prepareRuntimeIfNeeded(adapter, projectRoot, runtimeOptions) {
104
+ if (adapter !== "extension" || runtimeOptions.skipExtensionRuntimePrepare === true) return;
105
+ const { prepareExtensionRuntime } = await import("../adapters/extension/runtime.js");
106
+ await prepareExtensionRuntime({
107
+ projectRoot,
108
+ cdpPort: runtimeOptions.cdpPort,
109
+ slot: runtimeOptions.slot,
110
+ launchExistingDist: runtimeOptions.launchExistingDist === true,
111
+ validationRuntimeDir: runtimeOptions.validationRuntimeDir
112
+ });
113
+ }
114
+ function restoreEnv(key, value) {
115
+ if (value === void 0) delete process.env[key];
116
+ else process.env[key] = value;
117
+ }
118
+ async function validateRecipeAdapterAware(recipe, manifest, librarySources) {
119
+ const { validateRecipeDocument, validateRecipeWithManifest } = await importRecipeProtocol();
120
+ let externalFlowIds;
121
+ if (librarySources && librarySources.length > 0) {
122
+ const harness = await importRecipeHarness();
123
+ const resolution = await harness.loadRecipeLibraries(librarySources);
124
+ externalFlowIds = new Set(resolution.flows.keys());
125
+ }
126
+ const validationOptions = externalFlowIds !== void 0 ? { externalFlowIds } : void 0;
127
+ const schema = validateRecipeDocument(recipe, validationOptions);
128
+ const withManifest = validateRecipeWithManifest(recipe, manifest, validationOptions);
129
+ const findings = [...schema.findings, ...withManifest.findings];
130
+ const errors = findings.filter((finding) => finding.severity === "error").length;
131
+ const warnings = findings.length - errors;
132
+ return { status: errors > 0 ? "invalid" : "valid", findings, summary: { errors, warnings } };
133
+ }
134
+ async function validateRunRecipeStatic(recipeArg, adapter, options) {
135
+ const recipeFile = path.resolve(recipeArg);
136
+ const empty = { recipe: void 0, recipeFile, findings: [], errorCount: 0, manifestOk: false, schemaValid: false };
137
+ if (!fs.existsSync(recipeFile)) {
138
+ return { ...empty, usageError: { code: "RECIPE_NOT_FOUND", message: `recipe not found: ${recipeFile}` } };
139
+ }
140
+ let recipe;
141
+ try {
142
+ recipe = JSON.parse(fs.readFileSync(recipeFile, "utf8"));
143
+ } catch (error) {
144
+ return {
145
+ ...empty,
146
+ usageError: {
147
+ code: "RECIPE_UNPARSEABLE",
148
+ message: `recipe is not valid JSON: ${error instanceof Error ? error.message : String(error)}`
149
+ }
150
+ };
151
+ }
152
+ const findings = [];
153
+ const manifest = loadActionManifest(adapter, optionString(options, "actionManifest"));
154
+ let manifestOk = true;
155
+ try {
156
+ await validateManifest(manifest);
157
+ } catch (error) {
158
+ manifestOk = false;
159
+ findings.push({
160
+ severity: "error",
161
+ code: "manifest.invalid",
162
+ path: actionManifestPathOption(options, adapter),
163
+ message: error instanceof Error ? error.message : String(error)
164
+ });
165
+ }
166
+ const librarySources = await resolveMetaMaskLibrarySources(optionString(options, "library"));
167
+ const validation = manifestOk ? await validateRecipeAdapterAware(recipe, manifest, librarySources) : { status: "invalid", findings: [], summary: { errors: 1, warnings: 0 } };
168
+ findings.push(...validation.findings);
169
+ const errorCount = findings.filter((finding) => finding.severity === "error").length;
170
+ return { recipe, recipeFile, findings, errorCount, manifestOk, schemaValid: validation.status === "valid" };
171
+ }
172
+ async function resolveMetaMaskLibrarySources(libraryEntry) {
173
+ const harness = await importRecipeHarness();
174
+ if (typeof harness.resolveRecipeLibrarySources !== "function") {
175
+ if (libraryEntry) {
176
+ throw usageError(
177
+ "--library requires @farmslot/recipe-harness >= 0.3.3 (recipe-library support / resolveRecipeLibrarySources). The pinned 0.3.0 lacks it, and npm 0.3.2 still does not export it \u2014 recipe-library support is PENDING PUBLISH from farmslot."
178
+ );
179
+ }
180
+ return void 0;
181
+ }
182
+ const sources = await harness.resolveRecipeLibrarySources(
183
+ libraryEntry ? { cliEntries: [libraryEntry] } : void 0
184
+ );
185
+ sources.push({ name: "metamask", root: path.join(runnerDir, "library") });
186
+ return sources;
187
+ }
188
+ function synthesizeOneNodeRecipe(action, args) {
189
+ return {
190
+ schema_version: 1,
191
+ title: `mm-harness call ${action}`,
192
+ description: `Ad-hoc single-action execution of ${action} via the real engine path (mm-harness call).`,
193
+ validate: {
194
+ workflow: {
195
+ entry: "call",
196
+ nodes: {
197
+ call: { action, ...args, next: "done", intent: `Call ${action} in isolation` },
198
+ done: { action: "end", status: "pass" }
199
+ }
200
+ }
201
+ }
202
+ };
203
+ }
204
+ async function runOneNode(adapter, action, args, target, actionManifest) {
205
+ const manifest = loadActionManifest(adapter, actionManifest);
206
+ const recipe = synthesizeOneNodeRecipe(action, args);
207
+ const validation = await validateRecipeAdapterAware(recipe, manifest);
208
+ if (validation.status === "invalid") return { status: "fail" };
209
+ const scratch = await mkdtemp(path.join(os.tmpdir(), "mm-harness-fixtures-"));
210
+ const recipeFile = path.join(scratch, "set.recipe.json");
211
+ fs.writeFileSync(recipeFile, `${JSON.stringify(recipe, null, 2)}
212
+ `);
213
+ const result = await runRecipe(adapter, recipeFile, path.join(scratch, "artifacts"), target, actionManifest, {
214
+ librarySources: await resolveMetaMaskLibrarySources(void 0)
215
+ });
216
+ return { status: result.status === "pass" ? "pass" : "fail" };
217
+ }
218
+ async function prepareHeal(adapter, target, options, json) {
219
+ if (recipeRunning(target)) {
220
+ const msg = "a recipe is currently running \u2014 refusing to start while another recipe executes.";
221
+ if (json) {
222
+ console.log(JSON.stringify({ schemaVersion: 1, status: "fail", recoverable: false, error: { code: "RECIPE_RUNNING", message: msg } }, null, 2));
223
+ } else {
224
+ console.error(`\u2717 mm-harness: ${msg}`);
225
+ }
226
+ return EXIT.bounded;
227
+ }
228
+ const healValue = optionString(options, "heal");
229
+ const healOpts = {};
230
+ if (healValue !== void 0) healOpts.heal = healValue;
231
+ const heal = parseHeal(healOpts, "infra-only");
232
+ if (typeof heal !== "string") {
233
+ console.error(heal.error);
234
+ return EXIT.usage;
235
+ }
236
+ const state = newHealState();
237
+ const ensured = await ensureOverlay(adapter, target, heal, state, json);
238
+ if (!ensured.ok) {
239
+ if (json) {
240
+ console.log(
241
+ JSON.stringify(
242
+ { schemaVersion: 1, status: "fail", recoverable: false, mutations: state.mutations, error: { code: "OVERLAY_INSTALL_FAILED", message: ensured.error } },
243
+ null,
244
+ 2
245
+ )
246
+ );
247
+ } else {
248
+ console.error(`\u2717 overlay auto-ensure failed: ${ensured.error}`);
249
+ }
250
+ return EXIT.infra;
251
+ }
252
+ return { state, heal };
253
+ }
254
+ const RUN_RECOVERY_CODE = {
255
+ mobile: "metro.restarted",
256
+ extension: "chrome.reopened",
257
+ core: "runtime.reset"
258
+ };
259
+ function readRunFailureText(result) {
260
+ try {
261
+ const trace = JSON.parse(fs.readFileSync(result.tracePath, "utf8"));
262
+ const entries = Array.isArray(trace.entries) ? trace.entries : [];
263
+ return entries.filter((entry) => entry && entry.ok === false && typeof entry.error === "string").map((entry) => entry.error).join("\n").trim();
264
+ } catch {
265
+ return "";
266
+ }
267
+ }
268
+ async function executeWithHealBounds(exec, adapter, target, heal, state) {
269
+ let result = await exec();
270
+ for (; ; ) {
271
+ if (result.status === "pass") return { result, violation: null };
272
+ const violation = checkHealBounds(target, readRunFailureText(result), state);
273
+ if (violation !== null) return { result, violation };
274
+ if (heal === "off") return { result, violation: null };
275
+ const recoveryCode = RUN_RECOVERY_CODE[adapter];
276
+ state.attemptedRecoveries.push(recoveryCode);
277
+ result = await exec();
278
+ if (result.status === "pass") {
279
+ state.recovered.push(recoveryCode);
280
+ return { result, violation: null };
281
+ }
282
+ }
283
+ }
284
+ function emitHealViolation(json, command, result, violation, state, adapter) {
285
+ const userAction = adapter === "core" && violation.code === "WALLET_STATE_REQUIRED" ? 'set MM_TEST_ACCOUNT_ADDRESS=<0x\u2026> in env, or add "account": "<0x\u2026>" to the node block in the recipe' : violation.userAction;
286
+ if (json) {
287
+ console.log(
288
+ JSON.stringify(
289
+ {
290
+ schemaVersion: 1,
291
+ command,
292
+ status: "fail",
293
+ recoverable: false,
294
+ recovered: state.recovered,
295
+ mutations: state.mutations,
296
+ attemptedRecoveries: state.attemptedRecoveries,
297
+ summaryPath: result.summaryPath,
298
+ tracePath: result.tracePath,
299
+ artifactManifestPath: result.artifactManifestPath,
300
+ exitCode: violation.exitCode,
301
+ error: {
302
+ code: violation.code,
303
+ message: violation.message,
304
+ retryable: false,
305
+ userAction: userAction ?? null,
306
+ originalError: violation.originalError ?? null
307
+ }
308
+ },
309
+ null,
310
+ 2
311
+ )
312
+ );
313
+ } else {
314
+ console.error(
315
+ `\u2717 mm-harness ${command}: ${violation.message}` + (violation.originalError ? `
316
+ --- original failure ---
317
+ ${violation.originalError}` : "") + (userAction ? `
318
+ Next: ${userAction}` : "")
319
+ );
320
+ }
321
+ return violation.exitCode;
322
+ }
323
+ function countRecipeNodes(recipe) {
324
+ if (!isRecord(recipe)) return void 0;
325
+ const validate = isRecord(recipe.validate) ? recipe.validate : void 0;
326
+ const workflow = validate && isRecord(validate.workflow) ? validate.workflow : void 0;
327
+ const nodes = workflow && isRecord(workflow.nodes) ? workflow.nodes : void 0;
328
+ return nodes ? Object.keys(nodes).length : void 0;
329
+ }
330
+ export {
331
+ countRecipeNodes,
332
+ emitHealViolation,
333
+ executeWithHealBounds,
334
+ prepareHeal,
335
+ resolveMetaMaskLibrarySources,
336
+ runOneNode,
337
+ runRecipe,
338
+ synthesizeOneNodeRecipe,
339
+ validateRecipeAdapterAware,
340
+ validateRunRecipeStatic
341
+ };