@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,120 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { handleHarness } from "./harness.js";
5
+ import { recipeHarnessPath, recipeRuntimePath } from "./paths.js";
6
+ import { EXIT } from "./commands/shared.js";
7
+ function newHealState() {
8
+ return { recovered: [], mutations: [], attemptedRecoveries: [] };
9
+ }
10
+ function parseHeal(options, fallback) {
11
+ const value = options.heal;
12
+ if (value === void 0) return fallback;
13
+ if (value === "off" || value === "infra-only" || value === "auto") return value;
14
+ return { error: `--heal must be off, infra-only, or auto (got "${String(value)}").` };
15
+ }
16
+ function overlayDir(target, adapter) {
17
+ return recipeHarnessPath(target, adapter);
18
+ }
19
+ function overlayPresent(target, adapter) {
20
+ try {
21
+ return fs.statSync(overlayDir(target, adapter)).isDirectory();
22
+ } catch {
23
+ return false;
24
+ }
25
+ }
26
+ function overlayDelegateValid(target, adapter) {
27
+ if (adapter === "core") return true;
28
+ const pointer = path.join(recipeHarnessPath(target, adapter), "runner", ".runner-source");
29
+ if (!fs.existsSync(pointer)) return true;
30
+ const runnerPath = fs.readFileSync(pointer, "utf8").trim();
31
+ if (!Boolean(runnerPath) || !fs.existsSync(runnerPath)) return false;
32
+ return fs.existsSync(path.join(runnerPath, "bin", "mm-harness"));
33
+ }
34
+ async function ensureOverlay(adapter, target, heal, state, json) {
35
+ if (adapter === "core") return { ok: true };
36
+ if (overlayPresent(target, adapter) && overlayDelegateValid(target, adapter)) return { ok: true };
37
+ if (heal === "off") return { ok: true };
38
+ const installBin = process.env.MM_HARNESS_INSTALL_BIN;
39
+ let code;
40
+ if (installBin) {
41
+ const result = spawnSync(installBin, ["install", "--platform", adapter, "--target", target], {
42
+ cwd: target,
43
+ stdio: ["ignore", 2, "inherit"],
44
+ env: process.env
45
+ });
46
+ code = result.status ?? 1;
47
+ } else {
48
+ code = await handleHarness(["install", "--platform", adapter, "--target", target, ...json ? ["--json"] : []]);
49
+ }
50
+ if (code !== 0 || !overlayPresent(target, adapter)) {
51
+ return { ok: false, error: `runtime overlay install failed (exit ${code})` };
52
+ }
53
+ const dir = overlayDir(target, adapter);
54
+ state.mutations.push({ type: "file", action: "created", path: dir });
55
+ if (!json) process.stderr.write(`installed mm-harness overlay \u2192 ${dir}
56
+ `);
57
+ return { ok: true };
58
+ }
59
+ function classifyFailure(output) {
60
+ if (/wallet|fixture|keyring|not seeded|\bsrp\b|password|onboard/iu.test(output)) return "wallet";
61
+ if (/metro|cdp|chrome|bundle|packager|port\b|econnrefused|not reachable|watcher|dev client|websocket/iu.test(output)) {
62
+ return "infra";
63
+ }
64
+ return "app";
65
+ }
66
+ function recipeRunning(target) {
67
+ if (process.env.MM_HARNESS_RECIPE_RUNNING === "1") return true;
68
+ return fs.existsSync(recipeRuntimePath(target, "recipe.lock"));
69
+ }
70
+ const RECOVERY_CODE = {
71
+ mobile: "metro.restarted",
72
+ extension: "chrome.reopened"
73
+ };
74
+ function checkHealBounds(target, output, state) {
75
+ const originalError = output.trim() || void 0;
76
+ if (recipeRunning(target)) {
77
+ return {
78
+ code: "RECIPE_RUNNING",
79
+ exitCode: EXIT.bounded,
80
+ message: "a recipe is currently running \u2014 refusing recovery to avoid corrupting mid-run state.",
81
+ originalError
82
+ };
83
+ }
84
+ const failureClass = classifyFailure(output);
85
+ if (failureClass === "wallet") {
86
+ return {
87
+ code: "WALLET_STATE_REQUIRED",
88
+ exitCode: EXIT.bounded,
89
+ message: "recovery would require a seeded wallet \u2014 healing never touches fixtures.",
90
+ userAction: "run mm-harness fixtures set",
91
+ originalError
92
+ };
93
+ }
94
+ if (failureClass === "app") {
95
+ return {
96
+ code: "APP_LOGIC_FAILURE",
97
+ exitCode: EXIT.runtime,
98
+ message: "failure looks like app-logic \u2014 healing cannot help; surface verbatim.",
99
+ originalError
100
+ };
101
+ }
102
+ if (state.attemptedRecoveries.length > 0) {
103
+ return {
104
+ code: "SAME_RECOVERY_TWICE",
105
+ exitCode: EXIT.bounded,
106
+ message: "same recovery already failed once this invocation \u2014 refusing to loop.",
107
+ originalError
108
+ };
109
+ }
110
+ return null;
111
+ }
112
+ export {
113
+ RECOVERY_CODE,
114
+ checkHealBounds,
115
+ classifyFailure,
116
+ ensureOverlay,
117
+ newHealState,
118
+ parseHeal,
119
+ recipeRunning
120
+ };
package/dist/index.js ADDED
@@ -0,0 +1,25 @@
1
+ import { createDoctorReport } from "./doctor.js";
2
+ import { loadActionManifest, validateManifest } from "./manifest.js";
3
+ import { createMetaMaskExtensionRunner, createMetaMaskMobileRunner, createMetaMaskRunner } from "./runner.js";
4
+ import {
5
+ extensionIdPath,
6
+ importRecipeHarnessRuntimeBrowserExtension,
7
+ importRecipeHarnessRuntimeCdp,
8
+ recipeHarnessPath,
9
+ walletFixturePath
10
+ } from "./paths.js";
11
+ import { captureActiveRecipeRecordingSnapshot } from "./run-recording.js";
12
+ export {
13
+ captureActiveRecipeRecordingSnapshot,
14
+ createDoctorReport,
15
+ createMetaMaskExtensionRunner,
16
+ createMetaMaskMobileRunner,
17
+ createMetaMaskRunner,
18
+ extensionIdPath,
19
+ importRecipeHarnessRuntimeBrowserExtension,
20
+ importRecipeHarnessRuntimeCdp,
21
+ loadActionManifest,
22
+ recipeHarnessPath,
23
+ validateManifest,
24
+ walletFixturePath
25
+ };
@@ -0,0 +1,19 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ function resolveLeafInvoke(command, args) {
4
+ if (command.endsWith(".sh")) return { bin: "bash", args: [command, ...args] };
5
+ return { bin: command, args };
6
+ }
7
+ function missingShellLeafMessage(leafPath) {
8
+ const leaf = path.basename(leafPath);
9
+ return `leaf could not start: ${leaf} (ENOENT)
10
+ Next: reinstall mm-harness (npm i -g @deeeed/metamask-harness) \u2014 the shell leaf is missing or not executable`;
11
+ }
12
+ function shellLeafMissing(leafPath) {
13
+ return leafPath.endsWith(".sh") && !fs.existsSync(leafPath);
14
+ }
15
+ export {
16
+ missingShellLeafMessage,
17
+ resolveLeafInvoke,
18
+ shellLeafMissing
19
+ };
@@ -0,0 +1,240 @@
1
+ import { access, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { spawn } from "node:child_process";
6
+ import { resolveRequiredLocalProtocolRoot, runnerDir } from "./paths.js";
7
+ function actionFileStem(action) {
8
+ return String(action).replace(/[^a-zA-Z0-9._-]/g, "_");
9
+ }
10
+ function actionParts(action) {
11
+ const parts = String(action).split(".").filter(Boolean);
12
+ if (parts.length >= 3 && parts[0] === "metamask") {
13
+ return { family: parts[1], localName: parts.slice(2).join(".") };
14
+ }
15
+ if (parts.length >= 2) {
16
+ return { family: parts[0], localName: parts.slice(1).join(".") };
17
+ }
18
+ return { family: "actions", localName: String(action) };
19
+ }
20
+ function candidateStems(action) {
21
+ const stem = actionFileStem(action);
22
+ const { localName } = actionParts(action);
23
+ const localStem = actionFileStem(localName);
24
+ const stems = [stem];
25
+ if (localStem && localStem !== stem) stems.push(localStem);
26
+ return stems;
27
+ }
28
+ function candidateFamilies(action) {
29
+ const { family } = actionParts(action);
30
+ const families = [family];
31
+ return [...new Set(families)];
32
+ }
33
+ function candidatePaths(platform, action) {
34
+ const stems = candidateStems(action);
35
+ const families = candidateFamilies(action);
36
+ const roots = [
37
+ process.env.METAMASK_RECIPE_LIVE_ADAPTER_DIR,
38
+ path.join(runnerDir, "library/actions")
39
+ ].filter(Boolean);
40
+ const files = [];
41
+ for (const root of roots) {
42
+ for (const family of families) {
43
+ for (const candidateStem of stems) {
44
+ pushCandidateFiles(files, root, platform, family, candidateStem);
45
+ }
46
+ pushDomainDispatcherFiles(files, root, platform, family);
47
+ }
48
+ for (const candidateStem of stems) {
49
+ pushFlatCandidateFiles(files, root, platform, candidateStem);
50
+ }
51
+ }
52
+ return files;
53
+ }
54
+ function pushCandidateFiles(files, root, platform, family, stem) {
55
+ for (const extension of ["mjs", "js", "sh"]) {
56
+ files.push(path.join(root, platform, family, `${stem}.${extension}`));
57
+ files.push(path.join(root, "shared", family, `${stem}.${extension}`));
58
+ }
59
+ }
60
+ function pushDomainDispatcherFiles(files, root, platform, family) {
61
+ for (const extension of ["mjs", "js", "sh"]) {
62
+ files.push(path.join(root, platform, family, `${family}.${extension}`));
63
+ files.push(path.join(root, "shared", family, `${family}.${extension}`));
64
+ }
65
+ }
66
+ function pushFlatCandidateFiles(files, root, platform, stem) {
67
+ for (const extension of ["mjs", "js", "sh"]) {
68
+ files.push(path.join(root, platform, `${stem}.${extension}`));
69
+ files.push(path.join(root, "shared", `${stem}.${extension}`));
70
+ }
71
+ }
72
+ async function firstExecutablePath(paths) {
73
+ for (const file of paths) {
74
+ try {
75
+ await access(file);
76
+ return file;
77
+ } catch (error) {
78
+ if (error.code !== "ENOENT") throw error;
79
+ }
80
+ }
81
+ return null;
82
+ }
83
+ function runProcess(command, args, options) {
84
+ return new Promise((resolve, reject) => {
85
+ let settled = false;
86
+ const child = spawn(command, args, {
87
+ cwd: options.cwd,
88
+ env: options.env,
89
+ stdio: ["ignore", "pipe", "pipe"]
90
+ });
91
+ let stdout = "";
92
+ let stderr = "";
93
+ child.stdout.on("data", (chunk) => {
94
+ stdout += chunk;
95
+ });
96
+ child.stderr.on("data", (chunk) => {
97
+ stderr += chunk;
98
+ });
99
+ const timeout = options.timeoutMs ? setTimeout(() => {
100
+ if (settled) return;
101
+ settled = true;
102
+ child.kill("SIGTERM");
103
+ setTimeout(() => {
104
+ if (!child.killed) child.kill("SIGKILL");
105
+ }, 1e3);
106
+ resolve({ exitCode: null, stdout, stderr, timedOut: true });
107
+ }, options.timeoutMs) : void 0;
108
+ child.on("error", (error) => {
109
+ if (settled) return;
110
+ settled = true;
111
+ if (timeout) clearTimeout(timeout);
112
+ reject(error);
113
+ });
114
+ child.on("close", (exitCode) => {
115
+ if (settled) return;
116
+ settled = true;
117
+ if (timeout) clearTimeout(timeout);
118
+ resolve({ exitCode, stdout, stderr, timedOut: false });
119
+ });
120
+ });
121
+ }
122
+ function commandFor(file) {
123
+ if (file.endsWith(".sh")) return { command: "bash", args: [file] };
124
+ if (file.endsWith(".mjs") || file.endsWith(".js")) {
125
+ if (!importsSourceTypescript(file)) return { command: process.execPath, args: [file] };
126
+ }
127
+ const localTsx = path.join(runnerDir, "node_modules/.bin/tsx");
128
+ const tsxBin = process.env.TSX_BIN || (existsSync(localTsx) ? localTsx : path.join(
129
+ resolveRequiredLocalProtocolRoot("TypeScript live adapter execution"),
130
+ "node_modules/.bin/tsx"
131
+ ));
132
+ return { command: tsxBin, args: [file] };
133
+ }
134
+ function importsSourceTypescript(file) {
135
+ return importsSourceTypescriptFrom(file, /* @__PURE__ */ new Set());
136
+ }
137
+ function importsSourceTypescriptFrom(file, visited) {
138
+ const absolute = path.resolve(file);
139
+ if (visited.has(absolute)) return false;
140
+ visited.add(absolute);
141
+ const source = readFileSync(file, "utf8");
142
+ const importPattern = /(?:from\s+|import\(\s*)['"]([^'"]+)['"]/gu;
143
+ for (const match of source.matchAll(importPattern)) {
144
+ const specifier = match[1] ?? "";
145
+ if (specifier.endsWith(".ts")) return true;
146
+ if (!specifier.startsWith(".")) continue;
147
+ if (!specifier.endsWith(".mjs") && !specifier.endsWith(".js")) continue;
148
+ if (importsSourceTypescriptFrom(path.resolve(path.dirname(absolute), specifier), visited)) {
149
+ return true;
150
+ }
151
+ }
152
+ return false;
153
+ }
154
+ async function resolveLiveAdapter(platform, action) {
155
+ return firstExecutablePath(candidatePaths(platform, action));
156
+ }
157
+ const CORE_WORKSPACE_PACKAGES = [
158
+ "@metamask/base-controller",
159
+ "@metamask/messenger",
160
+ "@metamask/controller-utils",
161
+ "@metamask/keyring-controller"
162
+ ];
163
+ async function platformAdapterEnv(platform, projectRoot, tempDir) {
164
+ if (platform !== "core") return {};
165
+ const paths = {};
166
+ for (const pkg of CORE_WORKSPACE_PACKAGES) {
167
+ const packageDir = path.join(projectRoot, "packages", pkg.replace("@metamask/", ""));
168
+ const distEntry = path.join(packageDir, "dist/index.cjs");
169
+ if (existsSync(distEntry)) continue;
170
+ const srcDir = path.join(packageDir, "src");
171
+ if (!existsSync(path.join(srcDir, "index.ts"))) continue;
172
+ paths[pkg] = [path.join(srcDir, "index.ts")];
173
+ paths[`${pkg}/*`] = [path.join(srcDir, "*")];
174
+ }
175
+ if (Object.keys(paths).length === 0) return {};
176
+ const tsconfigPath = path.join(tempDir, "core-adapter.tsconfig.json");
177
+ await writeFile(
178
+ tsconfigPath,
179
+ `${JSON.stringify({ compilerOptions: { baseUrl: projectRoot, paths } }, null, 2)}
180
+ `
181
+ );
182
+ return { TSX_TSCONFIG_PATH: tsconfigPath };
183
+ }
184
+ async function runLiveAdapterScript({ platform, action, node, context }) {
185
+ const script = await resolveLiveAdapter(platform, action);
186
+ if (!script) return null;
187
+ const tempDir = await mkdtemp(path.join(os.tmpdir(), "mm-harness-live-adapter-"));
188
+ const inputPath = path.join(tempDir, "input.json");
189
+ const outputPath = path.join(tempDir, "output.json");
190
+ const input = {
191
+ schemaVersion: 1,
192
+ platform,
193
+ action,
194
+ node,
195
+ context: {
196
+ nodeId: context.nodeId,
197
+ projectRoot: context.projectRoot,
198
+ artifactsDir: context.artifactsDir
199
+ },
200
+ outputPath
201
+ };
202
+ await writeFile(inputPath, `${JSON.stringify(input, null, 2)}
203
+ `);
204
+ const command = commandFor(script);
205
+ const platformEnv = await platformAdapterEnv(platform, context.projectRoot, tempDir);
206
+ const result = await runProcess(command.command, [...command.args, inputPath], {
207
+ cwd: context.projectRoot,
208
+ env: {
209
+ ...process.env,
210
+ ...context.env,
211
+ ...platformEnv,
212
+ METAMASK_RECIPE_ADAPTER_INPUT: inputPath,
213
+ METAMASK_RECIPE_ADAPTER_OUTPUT: outputPath
214
+ },
215
+ timeoutMs: Number(node.live_adapter_timeout_ms ?? node.timeout_ms ?? 6e4)
216
+ });
217
+ try {
218
+ if (result.timedOut) {
219
+ throw new Error(`Live adapter ${script} timed out after ${Number(node.live_adapter_timeout_ms ?? node.timeout_ms ?? 6e4)}ms.`);
220
+ }
221
+ if (result.exitCode !== 0) {
222
+ throw new Error(`Live adapter ${script} exited ${result.exitCode}: ${result.stderr || result.stdout}`);
223
+ }
224
+ let parsed = null;
225
+ try {
226
+ parsed = JSON.parse(await readFile(outputPath, "utf8"));
227
+ } catch (_error) {
228
+ const stdout = result.stdout.trim();
229
+ if (!stdout) throw new Error(`Live adapter ${script} did not write JSON output.`);
230
+ parsed = JSON.parse(stdout);
231
+ }
232
+ return { script, result: parsed };
233
+ } finally {
234
+ await rm(tempDir, { recursive: true, force: true });
235
+ }
236
+ }
237
+ export {
238
+ resolveLiveAdapter,
239
+ runLiveAdapterScript
240
+ };
@@ -0,0 +1,37 @@
1
+ import path from "node:path";
2
+ import { manifestPath, readJson, importRecipeProtocol } from "./paths.js";
3
+ function loadMetaMaskMobileActionManifest() {
4
+ return asActionManifest(readJson(manifestPath("mobile")));
5
+ }
6
+ function loadMetaMaskExtensionActionManifest() {
7
+ return asActionManifest(readJson(manifestPath("extension")));
8
+ }
9
+ function loadMetaMaskCoreActionManifest() {
10
+ return asActionManifest(readJson(manifestPath("core")));
11
+ }
12
+ function loadActionManifest(adapter, overridePath) {
13
+ if (overridePath) return asActionManifest(readJson(path.resolve(overridePath)));
14
+ if (adapter === "mobile") return loadMetaMaskMobileActionManifest();
15
+ if (adapter === "core") return loadMetaMaskCoreActionManifest();
16
+ return loadMetaMaskExtensionActionManifest();
17
+ }
18
+ async function validateManifest(manifest) {
19
+ const { validateRecipeActionManifestDocument } = await importRecipeProtocol();
20
+ const result = validateRecipeActionManifestDocument(manifest);
21
+ if (result.status === "invalid") {
22
+ throw new Error(
23
+ `Manifest invalid: ${result.findings.map((finding) => `${finding.code} ${finding.path}`).join(", ")}`
24
+ );
25
+ }
26
+ return result;
27
+ }
28
+ function asActionManifest(value) {
29
+ return value;
30
+ }
31
+ export {
32
+ loadActionManifest,
33
+ loadMetaMaskCoreActionManifest,
34
+ loadMetaMaskExtensionActionManifest,
35
+ loadMetaMaskMobileActionManifest,
36
+ validateManifest
37
+ };