@deeeed/metamask-harness 0.4.0 → 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 (52) hide show
  1. package/CHANGELOG.md +19 -0
  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/CLI-SPEC.md +26 -3
  42. package/package.json +5 -1
  43. package/src/adapters/core/surface.ts +15 -0
  44. package/src/adapters/extension/surface.ts +20 -3
  45. package/src/adapters/mobile/provision.ts +594 -0
  46. package/src/adapters/mobile/surface.ts +16 -4
  47. package/src/adapters/slot-ports.ts +1 -1
  48. package/src/adapters/surface.ts +35 -0
  49. package/src/cli-commands.ts +1 -1
  50. package/src/cli.ts +149 -6
  51. package/src/harness.ts +140 -3
  52. package/src/mm-harness-cli.ts +52 -5
@@ -0,0 +1,157 @@
1
+ import { spawn, spawnSync } from "node:child_process";
2
+ import path from "node:path";
3
+ import { detectAdapter } from "../harness.js";
4
+ import { resolveLeafInvoke, shellLeafMissing } from "../leaf-invoke.js";
5
+ const EXIT = { ok: 0, runtime: 1, usage: 2, infra: 3, bounded: 4, validation: 5 };
6
+ function parseFlags(argv, booleans) {
7
+ const positional = [];
8
+ const options = {};
9
+ for (let i = 0; i < argv.length; i += 1) {
10
+ const arg = argv[i];
11
+ if (!arg.startsWith("--")) {
12
+ positional.push(arg);
13
+ continue;
14
+ }
15
+ const body = arg.slice(2);
16
+ const eq = body.indexOf("=");
17
+ const rawKey = eq === -1 ? body : body.slice(0, eq);
18
+ const inline = eq === -1 ? void 0 : body.slice(eq + 1);
19
+ const key = rawKey.replace(/-([a-z])/gu, (_, c) => c.toUpperCase());
20
+ if (booleans.has(key)) {
21
+ options[key] = inline === void 0 ? true : inline !== "false";
22
+ continue;
23
+ }
24
+ if (inline !== void 0) {
25
+ options[key] = inline;
26
+ continue;
27
+ }
28
+ const next = argv[i + 1];
29
+ if (next === void 0 || next.startsWith("--")) {
30
+ options[key] = true;
31
+ continue;
32
+ }
33
+ options[key] = next;
34
+ i += 1;
35
+ }
36
+ return { positional, options };
37
+ }
38
+ function str(options, key) {
39
+ const value = options[key];
40
+ return typeof value === "string" ? value : void 0;
41
+ }
42
+ function flag(options, key) {
43
+ return options[key] === true;
44
+ }
45
+ function targetOf(options) {
46
+ return path.resolve(str(options, "target") ?? str(options, "projectRoot") ?? process.cwd());
47
+ }
48
+ const ADAPTER_TOKENS = ["mobile", "extension", "core"];
49
+ function resolveAdapter(options, target, hint) {
50
+ const explicit = str(options, "adapter") ?? str(options, "platform");
51
+ if (explicit && ADAPTER_TOKENS.includes(explicit)) return explicit;
52
+ if (hint) return hint;
53
+ return detectAdapter(target);
54
+ }
55
+ function spawnScript(script, args, cwd, json, env) {
56
+ const isNodeScript = script === process.execPath && args.length > 0;
57
+ const stem = isNodeScript ? path.basename(args[0]).replace(/[^A-Za-z0-9]/gu, "_").toUpperCase() : path.basename(script).replace(/[^A-Za-z0-9]/gu, "_").toUpperCase();
58
+ const override = process.env[`MM_HARNESS_SCRIPT_BIN_${stem}`];
59
+ const bin = override ?? script;
60
+ const directArgs = override !== void 0 && isNodeScript ? args.slice(1) : args;
61
+ if (shellLeafMissing(bin)) {
62
+ const message = `leaf could not start: ${path.basename(bin)} (ENOENT)
63
+ Next: reinstall mm-harness (npm i -g @deeeed/metamask-harness) \u2014 the shell leaf is missing or not executable`;
64
+ process.stderr.write(`${message}
65
+ `);
66
+ return { status: 1, output: message };
67
+ }
68
+ const { bin: invokeBin, args: spawnArgs } = resolveLeafInvoke(bin, directArgs);
69
+ const result = spawnSync(invokeBin, spawnArgs, {
70
+ cwd,
71
+ encoding: "utf8",
72
+ env: env ? { ...process.env, ...env } : process.env,
73
+ maxBuffer: 64 * 1024 * 1024
74
+ });
75
+ if (result.error) {
76
+ const leaf = isNodeScript ? path.basename(args[0]) : path.basename(script);
77
+ const code = result.error.code ?? "ESPAWN";
78
+ const message = `leaf could not start: ${leaf} (${code})
79
+ Next: reinstall mm-harness (npm i -g @deeeed/metamask-harness) \u2014 the shell leaf is missing or not executable`;
80
+ process.stderr.write(`${message}
81
+ `);
82
+ return { status: 1, output: message };
83
+ }
84
+ const output = `${result.stdout ?? ""}${result.stderr ?? ""}`;
85
+ if (!json && output) process.stderr.write(output);
86
+ return { status: result.status ?? 1, output };
87
+ }
88
+ function spawnScriptStreaming(script, args, cwd, env) {
89
+ const isNodeScript = script === process.execPath && args.length > 0;
90
+ const stem = isNodeScript ? path.basename(args[0]).replace(/[^A-Za-z0-9]/gu, "_").toUpperCase() : path.basename(script).replace(/[^A-Za-z0-9]/gu, "_").toUpperCase();
91
+ const override = process.env[`MM_HARNESS_SCRIPT_BIN_${stem}`];
92
+ const bin = override ?? script;
93
+ const directArgs = override !== void 0 && isNodeScript ? args.slice(1) : args;
94
+ if (shellLeafMissing(bin)) {
95
+ const message = `leaf could not start: ${path.basename(bin)} (ENOENT)
96
+ Next: reinstall mm-harness (npm i -g @deeeed/metamask-harness) \u2014 the shell leaf is missing or not executable`;
97
+ process.stderr.write(`${message}
98
+ `);
99
+ return Promise.resolve({ status: 1, output: message });
100
+ }
101
+ const { bin: invokeBin, args: spawnArgs } = resolveLeafInvoke(bin, directArgs);
102
+ return new Promise((resolve) => {
103
+ const child = spawn(invokeBin, spawnArgs, {
104
+ cwd,
105
+ env: env ? { ...process.env, ...env } : process.env,
106
+ stdio: ["ignore", "pipe", "pipe"]
107
+ });
108
+ let output = "";
109
+ const tee = (chunk) => {
110
+ const text = chunk.toString("utf8");
111
+ output += text;
112
+ process.stderr.write(text);
113
+ };
114
+ child.stdout?.on("data", tee);
115
+ child.stderr?.on("data", tee);
116
+ child.on("error", (error) => {
117
+ const leaf = isNodeScript ? path.basename(args[0]) : path.basename(script);
118
+ const code = error.code ?? "ESPAWN";
119
+ const message = `leaf could not start: ${leaf} (${code})
120
+ Next: reinstall mm-harness (npm i -g @deeeed/metamask-harness) \u2014 the shell leaf is missing or not executable`;
121
+ process.stderr.write(`${message}
122
+ `);
123
+ resolve({ status: 1, output: message });
124
+ });
125
+ child.on("close", (status) => {
126
+ resolve({ status: status ?? 1, output });
127
+ });
128
+ });
129
+ }
130
+ import { ADAPTER_DETECT_NEXT } from "../harness.js";
131
+ function usageOut(json, command, message, userAction) {
132
+ if (json) {
133
+ console.log(
134
+ JSON.stringify(
135
+ { schemaVersion: 1, command, status: "fail", exitCode: EXIT.usage, error: { code: "USAGE", message, userAction } },
136
+ null,
137
+ 2
138
+ )
139
+ );
140
+ } else {
141
+ console.error(`\u2717 mm-harness ${command}: ${message}
142
+ Next: ${userAction}`);
143
+ }
144
+ return EXIT.usage;
145
+ }
146
+ export {
147
+ ADAPTER_DETECT_NEXT,
148
+ EXIT,
149
+ flag,
150
+ parseFlags,
151
+ resolveAdapter,
152
+ spawnScript,
153
+ spawnScriptStreaming,
154
+ str,
155
+ targetOf,
156
+ usageOut
157
+ };
@@ -0,0 +1,243 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { runnerDir } from "../paths.js";
6
+ import { EXIT, flag, parseFlags } from "./shared.js";
7
+ const PACKAGE_NAME = "@deeeed/metamask-harness";
8
+ const NUDGE_INTERVAL_MS = 24 * 60 * 60 * 1e3;
9
+ const NUDGE_FETCH_TIMEOUT_MS = 300;
10
+ function currentVersion() {
11
+ try {
12
+ const pkg = JSON.parse(fs.readFileSync(path.join(runnerDir, "package.json"), "utf8"));
13
+ return pkg.version ?? "0.0.0";
14
+ } catch {
15
+ return "0.0.0";
16
+ }
17
+ }
18
+ function parseVersion(value) {
19
+ const cleaned = value.replace(/^v/u, "");
20
+ const dash = cleaned.indexOf("-");
21
+ const core = dash === -1 ? cleaned : cleaned.slice(0, dash);
22
+ const pre = dash === -1 ? "" : cleaned.slice(dash + 1);
23
+ const nums = core.split(".").map((part) => Number.parseInt(part, 10) || 0);
24
+ while (nums.length < 3) nums.push(0);
25
+ return { nums, pre };
26
+ }
27
+ function isNewer(candidate, base) {
28
+ const a = parseVersion(candidate);
29
+ const b = parseVersion(base);
30
+ for (let i = 0; i < 3; i += 1) {
31
+ if (a.nums[i] !== b.nums[i]) return a.nums[i] > b.nums[i];
32
+ }
33
+ if (a.pre === b.pre) return false;
34
+ if (a.pre === "") return true;
35
+ if (b.pre === "") return false;
36
+ return a.pre > b.pre;
37
+ }
38
+ function fetchLatest(timeoutMs) {
39
+ const result = spawnSync("npm", ["view", PACKAGE_NAME, "dist-tags.latest"], {
40
+ encoding: "utf8",
41
+ timeout: timeoutMs,
42
+ stdio: ["ignore", "pipe", "pipe"]
43
+ });
44
+ if (result.error) {
45
+ const code = result.error.code;
46
+ if (code === "ENOENT") return { error: { kind: "no-npm", message: "npm was not found on PATH" } };
47
+ return { error: { kind: "unreachable", message: result.error.message } };
48
+ }
49
+ if (result.signal) {
50
+ return { error: { kind: "unreachable", message: `registry fetch timed out after ${timeoutMs}ms` } };
51
+ }
52
+ if (result.status !== 0) {
53
+ return { error: { kind: "unreachable", message: (result.stderr ?? "").trim() || `npm view exited ${result.status}` } };
54
+ }
55
+ const latest = (result.stdout ?? "").trim();
56
+ if (!latest) return { error: { kind: "unreachable", message: "npm returned no dist-tag for latest" } };
57
+ return { latest };
58
+ }
59
+ async function fetchLatestAsync(timeoutMs) {
60
+ const controller = new AbortController();
61
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
62
+ try {
63
+ const url = `https://registry.npmjs.org/-/package/${encodeURIComponent(PACKAGE_NAME)}/dist-tags`;
64
+ const res = await fetch(url, { signal: controller.signal });
65
+ if (!res.ok) return { error: { kind: "unreachable", message: `registry returned HTTP ${res.status}` } };
66
+ const data = await res.json();
67
+ const latest = data["latest"];
68
+ if (!latest) return { error: { kind: "unreachable", message: "npm registry returned no latest dist-tag" } };
69
+ return { latest };
70
+ } catch (err) {
71
+ const msg = err.name === "AbortError" ? `registry fetch timed out after ${timeoutMs}ms` : err.message;
72
+ return { error: { kind: "unreachable", message: msg } };
73
+ } finally {
74
+ clearTimeout(timer);
75
+ }
76
+ }
77
+ function teachFailure(json, current, code, message, userAction) {
78
+ if (json) {
79
+ console.log(
80
+ JSON.stringify(
81
+ { schemaVersion: 1, command: "update", status: "fail", current, error: { code, message, userAction } },
82
+ null,
83
+ 2
84
+ )
85
+ );
86
+ } else {
87
+ console.error(`\u2717 mm-harness update: ${message}
88
+ Next: ${userAction}`);
89
+ }
90
+ return EXIT.infra;
91
+ }
92
+ function emitFetchError(json, error, current) {
93
+ if (error.kind === "no-npm") {
94
+ return teachFailure(
95
+ json,
96
+ current,
97
+ "NO_NPM",
98
+ "npm was not found on PATH \u2014 cannot check for updates",
99
+ "install Node.js (which bundles npm) from https://nodejs.org, then re-run: mm-harness update"
100
+ );
101
+ }
102
+ return teachFailure(
103
+ json,
104
+ current,
105
+ "REGISTRY_UNREACHABLE",
106
+ `could not reach the npm registry (${error.message})`,
107
+ "check your network, then re-run: mm-harness update \u2014 or inspect the registry with: npm config get registry"
108
+ );
109
+ }
110
+ async function handleUpdate(argv) {
111
+ const { options } = parseFlags(argv, /* @__PURE__ */ new Set(["check", "json"]));
112
+ const json = flag(options, "json");
113
+ const checkOnly = flag(options, "check");
114
+ const current = currentVersion();
115
+ const fetched = fetchLatest(3e4);
116
+ if (fetched.error) return emitFetchError(json, fetched.error, current);
117
+ const latest = fetched.latest;
118
+ const updateAvailable = isNewer(latest, current);
119
+ if (checkOnly) {
120
+ if (json) {
121
+ console.log(JSON.stringify({ schemaVersion: 1, command: "update", current, latest, updateAvailable }, null, 2));
122
+ } else if (updateAvailable) {
123
+ console.error(`mm-harness ${current} \u2192 ${latest} available \xB7 run: mm-harness update`);
124
+ } else {
125
+ console.log(`mm-harness is up to date (${current}).`);
126
+ }
127
+ return updateAvailable ? EXIT.runtime : EXIT.ok;
128
+ }
129
+ if (!updateAvailable) {
130
+ if (json) {
131
+ console.log(JSON.stringify({ schemaVersion: 1, command: "update", current, latest, updateAvailable: false, updated: false }, null, 2));
132
+ } else {
133
+ console.log(`mm-harness is up to date (${current}).`);
134
+ }
135
+ return EXIT.ok;
136
+ }
137
+ const INSTALL_TIMEOUT_MS = 5 * 60 * 1e3;
138
+ const install = spawnSync("npm", ["i", "-g", `${PACKAGE_NAME}@latest`], {
139
+ encoding: "utf8",
140
+ timeout: INSTALL_TIMEOUT_MS,
141
+ stdio: ["ignore", "pipe", "pipe"]
142
+ });
143
+ if (install.error) {
144
+ const code = install.error.code;
145
+ if (code === "ENOENT") return emitFetchError(json, { kind: "no-npm", message: "npm was not found on PATH" }, current);
146
+ return teachFailure(json, current, "INSTALL_FAILED", `global install failed (${install.error.message})`, `run it directly to see the error: npm i -g ${PACKAGE_NAME}@latest`);
147
+ }
148
+ if (install.signal) {
149
+ return teachFailure(
150
+ json,
151
+ current,
152
+ "INSTALL_FAILED",
153
+ `global install timed out after ${INSTALL_TIMEOUT_MS / 1e3}s \u2014 slow network or registry unavailable`,
154
+ `check your network, then retry: mm-harness update \u2014 or install manually: npm i -g ${PACKAGE_NAME}@latest`
155
+ );
156
+ }
157
+ const combined = `${install.stdout ?? ""}${install.stderr ?? ""}`;
158
+ if (install.status !== 0) {
159
+ if (/EACCES|permission denied|EPERM/iu.test(combined)) {
160
+ return teachFailure(
161
+ json,
162
+ current,
163
+ "GLOBAL_DIR_PERMISSION",
164
+ "global install denied \u2014 the npm global directory is not writable",
165
+ "either re-run with sudo, or point npm at a user-writable prefix: npm config set prefix ~/.npm-global (then add ~/.npm-global/bin to PATH) and re-run: mm-harness update"
166
+ );
167
+ }
168
+ return teachFailure(
169
+ json,
170
+ current,
171
+ "INSTALL_FAILED",
172
+ `global install failed (${combined.trim() || `npm exited ${install.status}`})`,
173
+ `run it directly to see the error: npm i -g ${PACKAGE_NAME}@latest`
174
+ );
175
+ }
176
+ const rechecked = fetchLatest(3e4);
177
+ const installed = rechecked.latest ?? latest;
178
+ if (json) {
179
+ console.log(JSON.stringify({ schemaVersion: 1, command: "update", current, latest: installed, updateAvailable: true, updated: true, from: current, to: installed }, null, 2));
180
+ } else {
181
+ console.log(`mm-harness updated ${current} \u2192 ${installed}.`);
182
+ }
183
+ return EXIT.ok;
184
+ }
185
+ function cacheFile() {
186
+ const override = process.env.MM_HARNESS_UPDATE_CACHE;
187
+ if (override) return override;
188
+ const base = process.env.XDG_CACHE_HOME || path.join(os.homedir(), ".cache");
189
+ return path.join(base, "mm-harness", "update-check.json");
190
+ }
191
+ function readCache(file) {
192
+ try {
193
+ const data = JSON.parse(fs.readFileSync(file, "utf8"));
194
+ if (typeof data.lastCheck === "number" && typeof data.latest === "string") {
195
+ return { lastCheck: data.lastCheck, latest: data.latest };
196
+ }
197
+ } catch {
198
+ }
199
+ return null;
200
+ }
201
+ function writeCache(file, cache) {
202
+ try {
203
+ fs.mkdirSync(path.dirname(file), { recursive: true });
204
+ fs.writeFileSync(file, JSON.stringify(cache));
205
+ } catch {
206
+ }
207
+ }
208
+ function nudgeDisabled() {
209
+ return process.env.MM_HARNESS_NO_UPDATE_CHECK === "1" || Boolean(process.env.CI);
210
+ }
211
+ function nudgeLine(current, latest) {
212
+ return isNewer(latest, current) ? `mm-harness ${current} \u2192 ${latest} available \xB7 run: mm-harness update` : null;
213
+ }
214
+ async function maybeNudge(now = Date.now()) {
215
+ if (nudgeDisabled()) return;
216
+ const file = cacheFile();
217
+ const cache = readCache(file);
218
+ const current = currentVersion();
219
+ let latest = cache?.latest ?? "";
220
+ process.once("exit", () => {
221
+ const line = nudgeLine(current, latest);
222
+ if (line) process.stderr.write(`${line}
223
+ `);
224
+ });
225
+ if (!cache || now - cache.lastCheck >= NUDGE_INTERVAL_MS) {
226
+ writeCache(file, { lastCheck: now, latest });
227
+ const fetched = await fetchLatestAsync(NUDGE_FETCH_TIMEOUT_MS);
228
+ if (fetched.latest) {
229
+ latest = fetched.latest;
230
+ writeCache(file, { lastCheck: now, latest });
231
+ }
232
+ }
233
+ }
234
+ export {
235
+ PACKAGE_NAME,
236
+ currentVersion,
237
+ fetchLatest,
238
+ handleUpdate,
239
+ isNewer,
240
+ maybeNudge,
241
+ nudgeDisabled,
242
+ nudgeLine
243
+ };
@@ -0,0 +1,53 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { recipeRuntimePath } from "./paths.js";
4
+ const COMPLETION_CACHE_VERSION = 1;
5
+ const COMPLETION_CACHE_TTL_MS = 6e4;
6
+ function completionCachePath(projectRoot) {
7
+ return recipeRuntimePath(projectRoot, ".completion-cache.json");
8
+ }
9
+ function readCompletionCache(projectRoot) {
10
+ try {
11
+ const raw = JSON.parse(fs.readFileSync(completionCachePath(projectRoot), "utf8"));
12
+ if (!raw || raw.version !== COMPLETION_CACHE_VERSION || typeof raw.updatedAt !== "number") return void 0;
13
+ return raw;
14
+ } catch {
15
+ return void 0;
16
+ }
17
+ }
18
+ function isCacheFresh(cache, now = Date.now()) {
19
+ return now - cache.updatedAt < COMPLETION_CACHE_TTL_MS;
20
+ }
21
+ function readFreshCandidates(projectRoot, kind, now = Date.now()) {
22
+ const cache = readCompletionCache(projectRoot);
23
+ if (!cache || !isCacheFresh(cache, now)) return void 0;
24
+ return cache.candidates[kind];
25
+ }
26
+ function writeCompletionCandidates(projectRoot, kind, candidates) {
27
+ const existing = readCompletionCache(projectRoot);
28
+ const next = {
29
+ version: COMPLETION_CACHE_VERSION,
30
+ updatedAt: Date.now(),
31
+ candidates: { ...existing?.candidates ?? {}, [kind]: candidates }
32
+ };
33
+ const file = completionCachePath(projectRoot);
34
+ fs.mkdirSync(path.dirname(file), { recursive: true });
35
+ fs.writeFileSync(file, `${JSON.stringify(next, null, 2)}
36
+ `);
37
+ }
38
+ function invalidateCompletionCache(projectRoot) {
39
+ try {
40
+ fs.rmSync(completionCachePath(projectRoot));
41
+ } catch {
42
+ }
43
+ }
44
+ export {
45
+ COMPLETION_CACHE_TTL_MS,
46
+ COMPLETION_CACHE_VERSION,
47
+ completionCachePath,
48
+ invalidateCompletionCache,
49
+ isCacheFresh,
50
+ readCompletionCache,
51
+ readFreshCandidates,
52
+ writeCompletionCandidates
53
+ };
package/dist/doctor.js ADDED
@@ -0,0 +1,169 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { color } from "./cli-color.js";
4
+ import { readRuntimeContextField, resolveRuntimeContextPath } from "./harness.js";
5
+ import { manifestPath, readJson, recipeHarnessRoot, recipeRuntimeDir, runnerDir } from "./paths.js";
6
+ function repoShape(target) {
7
+ const exists = (rel) => fs.existsSync(path.join(target, rel));
8
+ const packageInfo = readPackageInfo(target);
9
+ const extensionProject = packageInfo.name === "metamask-crx" || exists("app/manifest") && exists("app/scripts") && exists("ui");
10
+ const mobileProject = packageInfo.name === "metamask" && exists("app/core") && (exists("ios") || exists("android"));
11
+ return {
12
+ packageName: packageInfo.name,
13
+ packageJsonStatus: packageInfo.status,
14
+ packageJsonError: packageInfo.error,
15
+ extensionProject,
16
+ mobileProject,
17
+ agenticService: exists("app/dev-tools/AgenticService/AgenticService.ts"),
18
+ mobileProductHarness: false,
19
+ mobileBridgeScript: true,
20
+ extensionRuntime: exists(`${recipeHarnessRoot()}/extension`),
21
+ injectedHarness: exists(`${recipeHarnessRoot()}/mobile`) || exists(`${recipeHarnessRoot()}/extension`),
22
+ walletFixture: exists(`${recipeRuntimeDir()}/wallet-fixture.json`)
23
+ };
24
+ }
25
+ function compatibilityMode(adapter, target) {
26
+ const shape = repoShape(target);
27
+ if (adapter === "core") {
28
+ return fs.existsSync(path.join(target, "packages/perps-controller/src/index.ts")) ? "headless controller (no bridge)" : "unsupported/no bridge";
29
+ }
30
+ if (adapter === "mobile") {
31
+ if (shape.injectedHarness && shape.agenticService) return "runner bridge with injected app bridge";
32
+ if (shape.agenticService) return "runner bridge with app bridge";
33
+ return "runner bridge available; app bridge not installed";
34
+ }
35
+ if (!shape.extensionProject) return "unsupported/no bridge";
36
+ if (shape.extensionRuntime || shape.injectedHarness) return "bridge present";
37
+ return "bridge injectable";
38
+ }
39
+ function readPackageInfo(target) {
40
+ const packageJsonPath = path.join(target, "package.json");
41
+ if (!fs.existsSync(packageJsonPath)) {
42
+ return { status: "missing", name: null, error: null };
43
+ }
44
+ try {
45
+ const data = readJsonObject(packageJsonPath);
46
+ return {
47
+ status: "valid",
48
+ name: typeof data.name === "string" ? data.name : null,
49
+ error: null
50
+ };
51
+ } catch (error) {
52
+ return {
53
+ status: "invalid",
54
+ name: null,
55
+ error: error instanceof Error ? error.message : String(error)
56
+ };
57
+ }
58
+ }
59
+ function fixtureSummary(target) {
60
+ const candidates = [
61
+ `${recipeRuntimeDir()}/wallet-fixture.json`
62
+ ];
63
+ const rel = candidates.find((candidate) => fs.existsSync(path.join(target, candidate)));
64
+ if (!rel) return { status: "missing", path: null };
65
+ try {
66
+ const data = readJsonObject(path.join(target, rel));
67
+ return {
68
+ status: Array.isArray(data.accounts) && data.accounts.length > 0 ? "ready" : "incomplete",
69
+ path: rel,
70
+ accountCount: Array.isArray(data.accounts) ? data.accounts.length : 0,
71
+ hasPassword: typeof data.password === "string" && data.password.length > 0
72
+ };
73
+ } catch (error) {
74
+ return { status: "invalid", path: rel, error: error instanceof Error ? error.message : String(error) };
75
+ }
76
+ }
77
+ const RUNTIME_CONTEXT_FIELDS = [
78
+ { key: "slotId", envVars: ["RECIPE_SLOT_ID"], envVar: "RECIPE_SLOT_ID", customize: "farmslot dispatch writes this", adapters: ["mobile", "extension", "core"] },
79
+ { key: "extensionId", envVars: ["RECIPE_HARNESS_EXTENSION_ID"], envVar: "RECIPE_HARNESS_EXTENSION_ID", customize: "auto-resolved; edit file to pin", adapters: ["extension"] },
80
+ { key: "cdpPort", envVars: ["RECIPE_CDP_PORT", "CDP_PORT"], envVar: "CDP_PORT", customize: "edit file or pass --cdp-port", adapters: ["extension"] },
81
+ { key: "runtimeStart.approved", envVars: ["RECIPE_RUNTIME_START_APPROVED"], envVar: "RECIPE_RUNTIME_START_APPROVED", customize: "edit file (true/false)", adapters: ["mobile", "extension"] },
82
+ { key: "runtimeStart.command", envVars: [], envVar: null, customize: "edit file", adapters: ["mobile", "extension"] },
83
+ { key: "runtimeStart.readyUrl", envVars: ["RECIPE_RUNTIME_READY_URL"], envVar: "RECIPE_RUNTIME_READY_URL", customize: "edit file", adapters: ["mobile", "extension"] }
84
+ ];
85
+ function runtimeContextSummary(target, adapter) {
86
+ const contextPath = resolveRuntimeContextPath(target);
87
+ const envOverride = process.env.RECIPE_RUNTIME_CONTEXT ?? null;
88
+ const fileExists = fs.existsSync(contextPath);
89
+ const file = envOverride ?? path.relative(target, contextPath);
90
+ const fields = {};
91
+ const specs = adapter ? RUNTIME_CONTEXT_FIELDS.filter((spec) => spec.adapters.includes(adapter)) : RUNTIME_CONTEXT_FIELDS;
92
+ for (const spec of specs) {
93
+ const envValue = spec.envVars.map((name) => process.env[name]).find((value) => value !== void 0 && value !== "");
94
+ if (envValue !== void 0) {
95
+ fields[spec.key] = { value: envValue, source: "env", envVar: spec.envVar, customize: spec.customize };
96
+ continue;
97
+ }
98
+ const fileValue = fileExists ? readRuntimeContextField(contextPath, spec.key) : void 0;
99
+ fields[spec.key] = fileValue !== void 0 ? { value: fileValue, source: "file", envVar: spec.envVar, customize: spec.customize } : { value: null, source: "default", envVar: spec.envVar, customize: spec.customize };
100
+ }
101
+ return { file, fileExists, envOverride, fields };
102
+ }
103
+ function renderRuntimeContext(runtimeContext) {
104
+ const out = (style, text) => color(style, text, { stream: process.stdout });
105
+ const lines = [];
106
+ lines.push(
107
+ runtimeContext.fileExists ? `${out("label", "runtime-context:")} ${runtimeContext.file} ${out("ok", "(present)")}` : `${out("label", "runtime-context:")} ${runtimeContext.file} ${out("dim", "(absent \u2014 written by farmslot prepare/dispatch)")}`
108
+ );
109
+ for (const [key, field] of Object.entries(runtimeContext.fields)) {
110
+ const isSet = field.value !== void 0 && field.value !== null && field.value !== "";
111
+ const value = isSet ? out("ok", String(field.value)) : out("dim", "(unset)");
112
+ const origin = field.source === "env" && field.envVar ? `env ${field.envVar}` : field.source;
113
+ const originTag = isSet ? out("accent", `[${origin}]`) : out("dim", `[${origin}]`);
114
+ lines.push(` ${key.padEnd(22)} ${value} ${originTag} ${out("dim", `\u2014 ${field.customize}`)}`);
115
+ }
116
+ return lines.join("\n");
117
+ }
118
+ function createDoctorReport(adapter, target, manifestValidation, actionManifestPath = manifestPath(adapter)) {
119
+ const mode = compatibilityMode(adapter, target);
120
+ const manifestErrors = Number(manifestValidation.summary?.errors ?? 0);
121
+ const status = manifestErrors > 0 ? "fail" : "pass";
122
+ const checks = [
123
+ {
124
+ id: "manifest",
125
+ status: manifestErrors === 0 ? "pass" : "fail",
126
+ message: manifestErrors === 0 ? "Action manifest is valid Recipe v1." : `Action manifest has ${manifestErrors} validation error(s).`
127
+ },
128
+ {
129
+ id: "bridge",
130
+ status: mode === "unsupported/no bridge" ? "fail" : "pass",
131
+ message: mode === "unsupported/no bridge" ? `No ${adapter} bridge is available for this checkout.` : `${adapter} compatibility mode: ${mode}.`
132
+ }
133
+ ];
134
+ return {
135
+ schemaVersion: 1,
136
+ protocolVersion: "v1",
137
+ runner_protocol_version: 1,
138
+ status,
139
+ checks: [...checks],
140
+ adapter,
141
+ target,
142
+ runner: {
143
+ name: "@metamask/recipe-runner",
144
+ runnerDir,
145
+ actionManifestPath,
146
+ harnessPackage: "@farmslot/recipe-harness"
147
+ },
148
+ compatibilityMode: mode,
149
+ shape: repoShape(target),
150
+ fixture: fixtureSummary(target),
151
+ runtimeContext: runtimeContextSummary(target, adapter),
152
+ manifestValidation: manifestValidation.summary
153
+ };
154
+ }
155
+ function readJsonObject(file) {
156
+ const value = readJson(file);
157
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
158
+ throw new Error(`Expected JSON object in ${file}`);
159
+ }
160
+ return value;
161
+ }
162
+ export {
163
+ compatibilityMode,
164
+ createDoctorReport,
165
+ fixtureSummary,
166
+ renderRuntimeContext,
167
+ repoShape,
168
+ runtimeContextSummary
169
+ };