@deeeed/metamask-harness 0.6.2 → 0.7.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 (37) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/adapters/extension/inject.mjs +7 -0
  3. package/adapters/extension/reattach.sh +210 -0
  4. package/adapters/extension/start-watch.sh +11 -17
  5. package/adapters/manifest.json +65 -9
  6. package/adapters/mobile/lib/tmux-viewer.sh +31 -10
  7. package/adapters/mobile/open-device.sh +14 -1
  8. package/adapters/mobile/stop-metro.sh +1 -1
  9. package/adapters/mobile/yarn-setup.sh +15 -1
  10. package/adapters/shared/activate-repo-ruby.sh +124 -0
  11. package/adapters/shared/open-log-window.sh +55 -0
  12. package/adapters/shared/resolve-farmslot-ports-core.mjs +3 -205
  13. package/adapters/shared/resolve-farmslot-ports.mjs +4 -19
  14. package/adapters/shared/resolve-farmslot-ports.sh +6 -104
  15. package/adapters/shared/resolve-slot-ports-core.mjs +213 -0
  16. package/adapters/shared/resolve-slot-ports.mjs +20 -0
  17. package/adapters/shared/resolve-slot-ports.sh +110 -0
  18. package/adapters/shared/tmux-session.sh +35 -0
  19. package/dist/adapters/extension/runtime-decision.js +4 -0
  20. package/dist/adapters/mobile/provision.js +25 -2
  21. package/dist/adapters/{resolve-farmslot-ports.js → resolve-slot-ports.js} +4 -2
  22. package/dist/adapters/slot-ports.js +8 -10
  23. package/dist/cli-commands.js +1 -1
  24. package/dist/commands/call.js +5 -0
  25. package/dist/commands/doctor.js +21 -1
  26. package/dist/commands/fixtures.js +5 -3
  27. package/dist/commands/launch/extension.js +80 -6
  28. package/dist/commands/launch/index.js +10 -1
  29. package/dist/commands/list-executables.js +48 -0
  30. package/dist/commands/logs.js +25 -2
  31. package/dist/commands/manifest.js +27 -7
  32. package/dist/commands/parse-args.js +1 -0
  33. package/dist/commands/run.js +3 -4
  34. package/dist/live-adapter-contract.js +30 -9
  35. package/dist/mm-harness-cli.js +3 -1
  36. package/dist/paths.js +4 -1
  37. package/package.json +1 -1
@@ -1,17 +1,88 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import http from "node:http";
1
3
  import fs from "node:fs";
2
4
  import path from "node:path";
5
+ import { depsCheck } from "@farmslot/recipe-harness/runtime/deps-readiness";
3
6
  import { recipeHarnessPath, recipeRuntimeDir, runnerDir } from "../../paths.js";
7
+ import { isExtensionDistStale } from "../../adapters/extension/runtime-decision.js";
4
8
  import { stopExtensionWatcher } from "../../adapters/slot-ports.js";
5
9
  import { spawnScriptStreaming } from "../shared.js";
10
+ function extensionDepsBlock(target) {
11
+ if (!fs.existsSync(path.join(target, "package.json"))) return null;
12
+ const deps = depsCheck(target);
13
+ const userAction = "yarn install --immutable # then re-run: mm-harness launch";
14
+ if (deps.status === "missing") {
15
+ return { message: "extension dependencies are not installed (no yarn install-state markers).", userAction };
16
+ }
17
+ if (deps.status === "stale") {
18
+ return {
19
+ message: "extension dependencies are stale (package.json/yarn.lock changed since the last install) \u2014 webpack would crash mid-build.",
20
+ userAction
21
+ };
22
+ }
23
+ return null;
24
+ }
6
25
  async function launchExtension(target, tier, wantWatch) {
7
- if (!wantWatch && tier === "quick") {
26
+ if (wantWatch) {
27
+ const startWatchSh = path.join(runnerDir, "adapters/extension/start-watch.sh");
28
+ const watchArgs = ["--target", target];
29
+ if (process.env.WATCHER_PORT) watchArgs.push("--watcher-port", process.env.WATCHER_PORT);
30
+ console.error(`\u2192 extension watch \u2014 webpack :${process.env.WATCHER_PORT ?? "default"} (output streams below)`);
31
+ return spawnScriptStreaming(startWatchSh, watchArgs, target);
32
+ }
33
+ if (tier === "build") {
8
34
  return extensionRebuild(target);
9
35
  }
10
- const startWatchSh = path.join(runnerDir, "adapters/extension/start-watch.sh");
11
- const watchArgs = ["--target", target];
12
- if (process.env.WATCHER_PORT) watchArgs.push("--watcher-port", process.env.WATCHER_PORT);
13
- console.error(`\u2192 extension ${wantWatch ? "watch" : "build"} \u2014 webpack :${process.env.WATCHER_PORT ?? "default"} (output streams below)`);
14
- return spawnScriptStreaming(startWatchSh, watchArgs, target);
36
+ if (await extensionRuntimeReusable(target)) {
37
+ return extensionReattach(target);
38
+ }
39
+ return extensionRebuild(target);
40
+ }
41
+ async function extensionRuntimeReusable(target) {
42
+ const watcherPort = process.env.WATCHER_PORT;
43
+ const cdpPort = process.env.CDP_PORT;
44
+ if (!watcherPort || !cdpPort) return false;
45
+ if (!portHasListener(watcherPort)) return false;
46
+ if (!await cdpVersionReachable(cdpPort)) return false;
47
+ if (isExtensionDistStale(target)) return false;
48
+ return true;
49
+ }
50
+ function portHasListener(port) {
51
+ try {
52
+ const out = execFileSync("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"], {
53
+ encoding: "utf8",
54
+ stdio: ["ignore", "pipe", "ignore"]
55
+ });
56
+ return out.split(/\s+/u).some((value) => /^\d+$/u.test(value));
57
+ } catch {
58
+ return false;
59
+ }
60
+ }
61
+ function cdpVersionReachable(port) {
62
+ return new Promise((resolve) => {
63
+ const request = http.get(
64
+ { host: "127.0.0.1", port: Number(port), path: "/json/version", timeout: 2e3 },
65
+ (response) => {
66
+ response.resume();
67
+ const code = response.statusCode ?? 0;
68
+ resolve(code >= 200 && code < 300);
69
+ }
70
+ );
71
+ request.on("timeout", () => {
72
+ request.destroy();
73
+ resolve(false);
74
+ });
75
+ request.on("error", () => resolve(false));
76
+ });
77
+ }
78
+ async function extensionReattach(target) {
79
+ const reattachScript = recipeHarnessPath(target, "extension", "scripts", "reattach.sh");
80
+ const reattachArgs = ["--target", target];
81
+ if (process.env.CDP_PORT) reattachArgs.push("--cdp-port", process.env.CDP_PORT);
82
+ if (process.env.WATCHER_PORT) reattachArgs.push("--watcher-port", process.env.WATCHER_PORT);
83
+ if (process.env.EXTENSION_START_URL) reattachArgs.push("--start-url", process.env.EXTENSION_START_URL);
84
+ console.error(`\u2192 extension quick reattach \u2014 reload in place \xB7 CDP :${process.env.CDP_PORT ?? "default"} (no rebuild, no relaunch)`);
85
+ return spawnScriptStreaming(reattachScript, reattachArgs, target);
15
86
  }
16
87
  async function extensionRebuild(target) {
17
88
  const runtimeDirRel = recipeRuntimeDir();
@@ -35,6 +106,9 @@ async function extensionRebuild(target) {
35
106
  return result;
36
107
  }
37
108
  export {
109
+ extensionDepsBlock,
110
+ extensionReattach,
38
111
  extensionRebuild,
112
+ extensionRuntimeReusable,
39
113
  launchExtension
40
114
  };
@@ -23,7 +23,7 @@ import {
23
23
  newHealState,
24
24
  parseHeal
25
25
  } from "../../heal-bounds.js";
26
- import { launchExtension } from "./extension.js";
26
+ import { extensionDepsBlock, extensionRuntimeReusable, launchExtension } from "./extension.js";
27
27
  import { launchMobile } from "./mobile.js";
28
28
  const LAUNCH_BOOLEANS = /* @__PURE__ */ new Set([
29
29
  "build",
@@ -111,6 +111,15 @@ async function handleLaunch(argv) {
111
111
  if (json) liveArgs.push("--json");
112
112
  return handleHarness(liveArgs);
113
113
  }
114
+ if (adapter === "extension") {
115
+ const willBuild = wantWatch || tier === "build" || !await extensionRuntimeReusable(target);
116
+ if (willBuild) {
117
+ const depsBlock = extensionDepsBlock(target);
118
+ if (depsBlock) {
119
+ return usageOut(json, "launch", depsBlock.message, depsBlock.userAction);
120
+ }
121
+ }
122
+ }
114
123
  let attempt = await executeComposition(adapter, mobileTarget, tier, wantWatch, target, json);
115
124
  if (attempt.status === 0) {
116
125
  return finishLaunch(json, adapter, mobileTarget, tier, displayMode, target, state);
@@ -0,0 +1,48 @@
1
+ import { loadActionManifest } from "../manifest.js";
2
+ import { importRecipeHarness, importRecipeProtocol } from "../paths.js";
3
+ import { EXIT } from "./shared.js";
4
+ import { optionFlag, optionString, resolveAdapter } from "./parse-args.js";
5
+ import { resolveMetaMaskLibrarySources } from "./run-engine.js";
6
+ const finalSegment = (name) => name.split(".").pop() ?? name;
7
+ async function listFlowIds() {
8
+ try {
9
+ const sources = await resolveMetaMaskLibrarySources(void 0);
10
+ if (!sources || sources.length === 0) return [];
11
+ const harness = await importRecipeHarness();
12
+ const resolution = await harness.loadRecipeLibraries(sources);
13
+ return [...resolution.flows.keys()].sort();
14
+ } catch {
15
+ return [];
16
+ }
17
+ }
18
+ async function handleListExecutables(command, options) {
19
+ const json = optionFlag(options, "json");
20
+ const { adapter } = resolveAdapter(options);
21
+ const manifest = loadActionManifest(adapter, optionString(options, "actionManifest"));
22
+ const { getRecipeActionManifestActionNames } = await importRecipeProtocol();
23
+ const names = getRecipeActionManifestActionNames(manifest).slice().sort();
24
+ const shortCounts = /* @__PURE__ */ new Map();
25
+ for (const name of names) shortCounts.set(finalSegment(name), (shortCounts.get(finalSegment(name)) ?? 0) + 1);
26
+ const actions = names.map((name) => {
27
+ const short = finalSegment(name);
28
+ return { name, short: shortCounts.get(short) === 1 ? short : null };
29
+ });
30
+ const flows = await listFlowIds();
31
+ if (json) {
32
+ console.log(JSON.stringify({ schemaVersion: 1, command, action: "list", adapter, actions, flows }, null, 2));
33
+ return EXIT.ok;
34
+ }
35
+ console.log(`invocable for ${adapter}:`);
36
+ console.log(` actions (mm-harness call <name> \u2014 short or full):`);
37
+ for (const entry of actions) {
38
+ console.log(entry.short ? ` ${entry.short} (${entry.name})` : ` ${entry.name} (full name only \u2014 ambiguous short)`);
39
+ }
40
+ if (flows.length > 0) {
41
+ console.log(` flows (mm-harness run <flow>):`);
42
+ for (const flow of flows) console.log(` ${flow}`);
43
+ }
44
+ return EXIT.ok;
45
+ }
46
+ export {
47
+ handleListExecutables
48
+ };
@@ -2,8 +2,8 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { runnerDir } from "../paths.js";
4
4
  import { getAdapterSurface } from "../adapters/surface.js";
5
- import { ADAPTER_DETECT_NEXT, EXIT, flag, parseFlags, resolveAdapter, spawnInherit, str, targetOf, usageOut } from "./shared.js";
6
- const LOGS_BOOLEANS = /* @__PURE__ */ new Set(["full", "json"]);
5
+ import { ADAPTER_DETECT_NEXT, EXIT, flag, parseFlags, resolveAdapter, spawnInherit, spawnScript, str, targetOf, usageOut } from "./shared.js";
6
+ const LOGS_BOOLEANS = /* @__PURE__ */ new Set(["full", "json", "window"]);
7
7
  async function handleLogs(argv) {
8
8
  const { options } = parseFlags(argv, LOGS_BOOLEANS);
9
9
  const json = flag(options, "json");
@@ -43,6 +43,29 @@ async function handleLogs(argv) {
43
43
  surface.hints.launch
44
44
  );
45
45
  }
46
+ if (flag(options, "window")) {
47
+ surface.resolveSlotPorts(target);
48
+ const port = process.env.WATCHER_PORT ?? process.env.METRO_PORT ?? "default";
49
+ const windowName = adapter === "mobile" ? `metro-${port}` : `webpack-${port}`;
50
+ const devLog = logSources[0].path;
51
+ const leaf = path.join(runnerDir, "adapters/shared/open-log-window.sh");
52
+ const result = spawnScript(
53
+ leaf,
54
+ ["--window", windowName, "--log", devLog, "--runtime-dir", path.dirname(devLog)],
55
+ target,
56
+ json
57
+ );
58
+ if (json) {
59
+ console.log(
60
+ JSON.stringify(
61
+ { schemaVersion: 1, command: "logs", action: "window", adapter, window: windowName, logFile: devLog, exitCode: result.status === 0 ? EXIT.ok : EXIT.runtime },
62
+ null,
63
+ 2
64
+ )
65
+ );
66
+ }
67
+ return result.status === 0 ? EXIT.ok : EXIT.runtime;
68
+ }
46
69
  if (json) {
47
70
  console.log(JSON.stringify({ schemaVersion: 1, command: "logs", adapter, source, logFile, exitCode: EXIT.ok }, null, 2));
48
71
  return EXIT.ok;
@@ -1,4 +1,5 @@
1
1
  import { loadActionManifest, validateManifest } from "../manifest.js";
2
+ import { EXIT } from "./shared.js";
2
3
  import {
3
4
  actionManifestPathOption,
4
5
  isRecord,
@@ -19,9 +20,22 @@ async function handleActions({ options }) {
19
20
  const { adapter } = resolveAdapter(options);
20
21
  const manifest = loadActionManifest(adapter, optionString(options, "actionManifest"));
21
22
  await validateManifest(manifest);
23
+ const json = optionFlag(options, "json");
22
24
  const action = optionString(options, "action");
23
- const actions = describeManifestActions(manifest, action);
24
- if (optionFlag(options, "json")) {
25
+ const all = describeManifestActions(manifest);
26
+ const actions = action ? fuzzyResolveActions(all, action) : all;
27
+ if (action && actions.length === 0) {
28
+ const message = `no action matches "${action}" for the ${adapter} adapter.`;
29
+ const userAction = `mm-harness actions --adapter ${adapter} # list the vocabulary`;
30
+ if (json) {
31
+ console.log(JSON.stringify({ schemaVersion: 1, command: "actions", adapter, action, error: { code: "ACTION_UNKNOWN", message, userAction } }, null, 2));
32
+ } else {
33
+ console.error(`\u2717 mm-harness actions: ${message}
34
+ Next: ${userAction}`);
35
+ }
36
+ return EXIT.usage;
37
+ }
38
+ if (json) {
25
39
  console.log(JSON.stringify({ adapter, actions }, null, 2));
26
40
  } else {
27
41
  for (const entry of actions) {
@@ -31,7 +45,15 @@ async function handleActions({ options }) {
31
45
  }
32
46
  return 0;
33
47
  }
34
- function describeManifestActions(manifest, filterAction) {
48
+ function fuzzyResolveActions(entries, query) {
49
+ const exactFull = entries.filter((e) => e.name === query);
50
+ if (exactFull.length > 0) return exactFull;
51
+ const finalSegment = (name) => name.split(".").pop() ?? name;
52
+ const exactSegment = entries.filter((e) => finalSegment(e.name) === query);
53
+ if (exactSegment.length > 0) return exactSegment;
54
+ return entries.filter((e) => finalSegment(e.name).includes(query));
55
+ }
56
+ function describeManifestActions(manifest) {
35
57
  const manifestRecord = isRecord(manifest) ? manifest : {};
36
58
  const metadata = isRecord(manifestRecord.action_metadata) ? manifestRecord.action_metadata : {};
37
59
  const official = Array.isArray(manifestRecord.supported_official_actions) ? manifestRecord.supported_official_actions.filter((value) => typeof value === "string") : [];
@@ -45,12 +67,10 @@ function describeManifestActions(manifest, filterAction) {
45
67
  }
46
68
  return [];
47
69
  }) : [];
48
- const entries = [
70
+ return [
49
71
  ...official.map((name) => describeManifestAction(name, "official", metadata[name])),
50
72
  ...custom.map((entry) => describeManifestAction(entry.name, "custom", entry.metadata))
51
- ].filter((entry) => !filterAction || entry.name === filterAction);
52
- if (filterAction && entries.length === 0) throw new Error(`Action not found in manifest: ${filterAction}`);
53
- return entries;
73
+ ];
54
74
  }
55
75
  function describeManifestAction(name, kind, metadata) {
56
76
  const record = isRecord(metadata) ? metadata : {};
@@ -23,6 +23,7 @@ function parseArgs(argv, command) {
23
23
  "startWatch",
24
24
  "record",
25
25
  "plan",
26
+ "list",
26
27
  "raw",
27
28
  "fix",
28
29
  "force",
@@ -3,13 +3,11 @@ import path from "node:path";
3
3
  import { walletFixturePath } from "../paths.js";
4
4
  import { EXIT } from "./shared.js";
5
5
  import {
6
- adapterOption,
7
6
  optionFlag,
8
7
  optionString,
9
8
  requiredOption,
10
9
  resolveAdapter,
11
10
  runtimeOptionsFromCli,
12
- targetPath,
13
11
  usageError
14
12
  } from "./parse-args.js";
15
13
  import {
@@ -21,13 +19,14 @@ import {
21
19
  runRecipe,
22
20
  validateRunRecipeStatic
23
21
  } from "./run-engine.js";
22
+ import { handleListExecutables } from "./list-executables.js";
24
23
  async function handleRun({ positional, options }) {
24
+ if (optionFlag(options, "list")) return handleListExecutables("run", options);
25
25
  const targetRecipe = positional[0];
26
26
  if (!targetRecipe) throw usageError("run requires <recipe.json>.");
27
27
  if (optionFlag(options, "plan")) return handleRunPlan(targetRecipe, options);
28
- const adapter = adapterOption(options);
28
+ const { adapter, target } = resolveAdapter(options);
29
29
  const json = optionFlag(options, "json");
30
- const target = targetPath(options);
31
30
  const artifactsDir = requiredOption(options, "artifactsDir", "run requires --artifacts-dir <dir>.");
32
31
  const prepared = await prepareHeal(adapter, target, options, json);
33
32
  if (typeof prepared === "number") return prepared;
@@ -119,18 +119,39 @@ function runProcess(command, args, options) {
119
119
  });
120
120
  });
121
121
  }
122
- function commandFor(file) {
122
+ function commandFor(file, projectRoot, platform) {
123
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] };
124
+ const needsTsx = platform === "core" || importsSourceTypescript(file);
125
+ if (!needsTsx && (file.endsWith(".mjs") || file.endsWith(".js"))) {
126
+ return { command: process.execPath, args: [file] };
127
+ }
128
+ const tsxBin = resolveTsxBin(projectRoot);
129
+ if (!tsxBin) {
130
+ const where = projectRoot ?? "the checkout";
131
+ const error = new Error(
132
+ `this action runs TypeScript from the checkout but no tsx runtime was found.
133
+ Next: run 'yarn install' in ${where} (tsx is a dev dependency of the checkout); if package imports still fail, run 'yarn build' there`
134
+ );
135
+ error.exitCode = 2;
136
+ throw error;
126
137
  }
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
138
  return { command: tsxBin, args: [file] };
133
139
  }
140
+ function resolveTsxBin(projectRoot) {
141
+ if (process.env.TSX_BIN) return process.env.TSX_BIN;
142
+ const candidates = [];
143
+ if (projectRoot) candidates.push(path.join(projectRoot, "node_modules/.bin/tsx"));
144
+ candidates.push(path.join(runnerDir, "node_modules/.bin/tsx"));
145
+ for (const candidate of candidates) {
146
+ if (existsSync(candidate)) return candidate;
147
+ }
148
+ try {
149
+ const protocolTsx = path.join(resolveRequiredLocalProtocolRoot("TypeScript live adapter execution"), "node_modules/.bin/tsx");
150
+ if (existsSync(protocolTsx)) return protocolTsx;
151
+ } catch {
152
+ }
153
+ return null;
154
+ }
134
155
  function importsSourceTypescript(file) {
135
156
  return importsSourceTypescriptFrom(file, /* @__PURE__ */ new Set());
136
157
  }
@@ -201,7 +222,7 @@ async function runLiveAdapterScript({ platform, action, node, context }) {
201
222
  };
202
223
  await writeFile(inputPath, `${JSON.stringify(input, null, 2)}
203
224
  `);
204
- const command = commandFor(script);
225
+ const command = commandFor(script, context.projectRoot, platform);
205
226
  const platformEnv = await platformAdapterEnv(platform, context.projectRoot, tempDir);
206
227
  const result = await runProcess(command.command, [...command.args, inputPath], {
207
228
  cwd: context.projectRoot,
@@ -230,8 +230,10 @@ Example:
230
230
  Launch the app \u2014 quick relaunch by default; --build for a full native/webpack build.
231
231
  Mobile: target is MANDATORY (ios | android). Extension: no target (--fullscreen default;
232
232
  --sidepanel to override). core is headless (teaching error \u2014 use verify/run).
233
+ Extension quick launch REUSES a healthy live runtime (reload in place, no rebuild);
234
+ --build is the escape hatch that always clean-builds + relaunches a fresh runtime.
233
235
 
234
- --build Full native/webpack build tier (default = quick relaunch)
236
+ --build Full clean build + relaunch (escape hatch; skips extension reuse)
235
237
  --verify Launch then poll CDP/bridge until ready (absorbs the old \`live\`)
236
238
  --runway Post-launch runway check (mobile only; teaching error elsewhere)
237
239
  --watch Persistent webpack watcher then relaunch (extension only)
package/dist/paths.js CHANGED
@@ -47,6 +47,9 @@ function recipeWatchLogCandidates() {
47
47
  }
48
48
  function resolveLocalProtocolRoot() {
49
49
  const candidates = [
50
+ // Orchestrator-neutral name is primary; FARMSLOT_ROOT stays a back-compat alias
51
+ // for one release (the installer injects METAMASK_RUNNER_PROTOCOL_ROOT).
52
+ process.env.METAMASK_RUNNER_PROTOCOL_ROOT,
50
53
  process.env.FARMSLOT_ROOT,
51
54
  readConfiguredProtocolRoot(),
52
55
  findProtocolRoot(runnerDir),
@@ -59,7 +62,7 @@ function resolveRequiredLocalProtocolRoot(reason) {
59
62
  const root = resolveLocalProtocolRoot();
60
63
  if (!root) {
61
64
  throw new Error(
62
- `${reason} requires a local protocol/runtime checkout. Set FARMSLOT_ROOT or create .farmslot-root for this dev-only path.`
65
+ `${reason} requires a local protocol/runtime checkout. Set METAMASK_RUNNER_PROTOCOL_ROOT (or the legacy FARMSLOT_ROOT), or create .farmslot-root for this dev-only path.`
63
66
  );
64
67
  }
65
68
  return root;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deeeed/metamask-harness",
3
- "version": "0.6.2",
3
+ "version": "0.7.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mm-harness": "bin/mm-harness"