@deeeed/metamask-harness 0.5.0 → 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 (102) hide show
  1. package/CHANGELOG.md +51 -20
  2. package/adapters/core/inject.sh +1 -0
  3. package/adapters/extension/cleanup.mjs +1 -1
  4. package/adapters/extension/inject.mjs +4 -1
  5. package/adapters/extension/launch-browser.cjs +18 -4
  6. package/adapters/extension/live.sh +14 -2
  7. package/adapters/extension/refresh-build.sh +2 -2
  8. package/adapters/extension/sidepanel-toggle.sh +33 -12
  9. package/adapters/extension/start-watch.sh +2 -2
  10. package/adapters/extension/wallet-fixture-state.cjs +38 -24
  11. package/adapters/manifest.json +32 -3
  12. package/adapters/mobile/inject.sh +1 -0
  13. package/adapters/mobile/start-metro.sh +1 -1
  14. package/adapters/mobile/stop-metro.sh +20 -0
  15. package/adapters/mobile/verify.sh +1 -1
  16. package/adapters/shared/activate-repo-node.sh +1 -1
  17. package/adapters/shared/cli-ux.sh +44 -25
  18. package/adapters/shared/ensure-runner-deps.sh +30 -0
  19. package/adapters/shared/log-tui.mjs +4 -4
  20. package/adapters/shared/open-debug.mjs +1 -1
  21. package/adapters/shared/reap-checkout-metros.sh +53 -0
  22. package/adapters/shared/recipe-harness-root.mjs +23 -0
  23. package/adapters/shared/resolve-farmslot-ports-core.mjs +205 -0
  24. package/adapters/shared/resolve-farmslot-ports.mjs +20 -0
  25. package/adapters/shared/resolve-farmslot-ports.sh +19 -126
  26. package/bin/mm-harness +45 -4
  27. package/dist/adapters/extension/runtime.js +3 -1
  28. package/dist/adapters/mobile/provision.js +34 -1
  29. package/dist/adapters/mobile/runtime-decision.js +3 -1
  30. package/dist/adapters/resolve-farmslot-ports.js +22 -0
  31. package/dist/adapters/slot-ports.js +18 -29
  32. package/dist/cli.js +32 -1463
  33. package/dist/commands/call.js +183 -0
  34. package/dist/commands/completion-candidates.js +58 -0
  35. package/dist/commands/doctor.js +101 -0
  36. package/dist/commands/ensure-ready.js +24 -0
  37. package/dist/commands/flows.js +62 -0
  38. package/dist/commands/launch/extension.js +40 -0
  39. package/dist/commands/{launch.js → launch/index.js} +15 -47
  40. package/dist/commands/launch/mobile.js +10 -0
  41. package/dist/commands/manifest.js +72 -0
  42. package/dist/commands/parse-args.js +189 -0
  43. package/dist/commands/provision.js +136 -0
  44. package/dist/commands/resolve-extension.js +23 -0
  45. package/dist/commands/run-engine.js +341 -0
  46. package/dist/commands/run.js +217 -0
  47. package/dist/commands/runtime-decision.js +58 -0
  48. package/dist/commands/runtime-health.js +25 -0
  49. package/dist/commands/runtime-launch.js +139 -0
  50. package/dist/commands/self-test.js +52 -0
  51. package/dist/commands/stop.js +52 -0
  52. package/dist/harness.js +8 -48
  53. package/dist/mm-harness-cli.js +13 -8
  54. package/docs/CLI-SPEC.md +1 -1
  55. package/docs/CODE-MAP.md +62 -0
  56. package/library/README.md +14 -0
  57. package/library/actions/core/perps/_controller.mjs +1 -1
  58. package/library/actions/extension/platform/cdp.mjs +2 -2
  59. package/library/actions/extension/wallet/ensure_unlocked.mjs +7 -1
  60. package/library/actions/harness-exports.mjs +27 -0
  61. package/library/actions/mobile/wallet/ensure_unlocked.mjs +14 -2
  62. package/library/actions/mobile/wallet/setup.mjs +1 -1
  63. package/package.json +6 -7
  64. package/src/adapters/core/surface.ts +0 -71
  65. package/src/adapters/extension/ensure-ready.ts +0 -185
  66. package/src/adapters/extension/extension-id.ts +0 -107
  67. package/src/adapters/extension/runtime-decision.ts +0 -445
  68. package/src/adapters/extension/runtime.ts +0 -407
  69. package/src/adapters/extension/surface.ts +0 -88
  70. package/src/adapters/mobile/deps-markers.ts +0 -21
  71. package/src/adapters/mobile/prepare.ts +0 -246
  72. package/src/adapters/mobile/provision.ts +0 -594
  73. package/src/adapters/mobile/runtime-decision.ts +0 -459
  74. package/src/adapters/mobile/surface.ts +0 -71
  75. package/src/adapters/slot-ports.ts +0 -165
  76. package/src/adapters/surface.ts +0 -117
  77. package/src/adapters.ts +0 -601
  78. package/src/cli-color.ts +0 -92
  79. package/src/cli-commands.ts +0 -250
  80. package/src/cli-version.ts +0 -141
  81. package/src/cli.ts +0 -2091
  82. package/src/commands/debug.ts +0 -65
  83. package/src/commands/fixtures.ts +0 -198
  84. package/src/commands/launch.ts +0 -470
  85. package/src/commands/logs.ts +0 -99
  86. package/src/commands/shared.ts +0 -235
  87. package/src/commands/update.ts +0 -316
  88. package/src/completions-cache.ts +0 -86
  89. package/src/doctor.ts +0 -215
  90. package/src/harness.ts +0 -797
  91. package/src/heal-bounds.ts +0 -198
  92. package/src/index.ts +0 -15
  93. package/src/leaf-invoke.ts +0 -28
  94. package/src/live-adapter-contract.ts +0 -274
  95. package/src/manifest.ts +0 -47
  96. package/src/mm-harness-cli.ts +0 -655
  97. package/src/paths.ts +0 -198
  98. package/src/progress.ts +0 -117
  99. package/src/recording-target.ts +0 -147
  100. package/src/run-recording.ts +0 -329
  101. package/src/runner.ts +0 -108
  102. package/src/types.ts +0 -57
package/src/harness.ts DELETED
@@ -1,797 +0,0 @@
1
- import { execFileSync, spawnSync } from 'node:child_process';
2
- import fs from 'node:fs';
3
- import path from 'node:path';
4
-
5
- import { resolveLeafInvoke, shellLeafMissing, missingShellLeafMessage } from './leaf-invoke.ts';
6
- import { recipeHarnessPath, recipeRuntimeDir, runnerDir } from './paths.ts';
7
- import type { MetaMaskRecipeAdapter } from './types.ts';
8
- import { prepareMobile } from './adapters/mobile/prepare.ts';
9
-
10
- // The `harness` command moves the recipe-harness skill's runner-locate + dispatch
11
- // bash (resolve-runner-source.sh + recipe-harness.sh dispatch_adapter_action)
12
- // into typed runner code. The skill resolved an *external* runner source; inside
13
- // the runner the root is known, so "resolve runner root" collapses to runnerDir.
14
- // Everything else — the per-subcommand/platform orchestration file mapping,
15
- // candidate search order, installed-overlay/installed-script preference, the
16
- // core delegate, extension runtime-context env, and verbatim arg passthrough —
17
- // is reproduced exactly so the skill can become a thin caller.
18
-
19
- type HarnessAction = 'install' | 'provision' | 'verify' | 'cleanup' | 'live';
20
-
21
- const HARNESS_ACTIONS: readonly HarnessAction[] = ['install', 'provision', 'verify', 'cleanup', 'live'];
22
- const ADAPTERS: readonly MetaMaskRecipeAdapter[] = ['mobile', 'extension', 'core'];
23
-
24
- // Mirror of scripts/lib/cli-common.sh valid_adapter_action, restricted to the
25
- // subcommands this command exposes (core has no app/live lifecycle).
26
- function isValidAdapterAction(adapter: MetaMaskRecipeAdapter, action: HarnessAction): boolean {
27
- if (adapter === 'core') return action === 'install' || action === 'provision' || action === 'verify' || action === 'cleanup';
28
- return true;
29
- }
30
-
31
- function harnessUsage(): void {
32
- console.error(`mm-harness — install and validate the MetaMask recipe runtime in a checkout.
33
-
34
- Run it from inside a MetaMask checkout; the platform (mobile | extension | core)
35
- is auto-detected from the repo. Pass --platform only to override.
36
-
37
- Commands (one copy-pasteable example each):
38
- install Install the recipe harness runtime overlay into the checkout.
39
- mm-harness install
40
- provision Install the cached Runway mobile dev client (same path as install --runway).
41
- mm-harness provision runway ios --adapter mobile
42
- verify Check the harness/runtime is present and healthy (no app launch).
43
- mm-harness verify
44
- cleanup Remove the installed harness overlay and restore the checkout.
45
- mm-harness cleanup
46
-
47
- Options:
48
- --platform <mobile|extension|core> Override auto-detection (alias: --adapter).
49
- --target <repo> Checkout to operate on (default: current dir).
50
- --json Machine-readable summary for agents/scripts.
51
- -- <args> Forward the rest verbatim to the underlying
52
- script, e.g. -- --platform ios --preflight-mode fast
53
- (mobile) or -- --cdp-port 6665 (extension).
54
-
55
- Examples:
56
- mm-harness install # inside a checkout, auto-detected
57
- mm-harness verify --platform extension
58
- mm-harness cleanup --target ../metamask-mobile
59
-
60
- core supports install, verify, and cleanup (headless; no live).`);
61
- }
62
-
63
- function failureHint(adapter: MetaMaskRecipeAdapter, action: HarnessAction): string {
64
- if (adapter === 'core') {
65
- if (action === 'verify') {
66
- return `Install the core harness first: mm-harness install --platform core`;
67
- }
68
- return 'Read the error above for the specific cause, then re-run this command.';
69
- }
70
- const helper = adapter === 'mobile' ? 'mm-harness launch ios # or launch android' : 'mm-harness launch';
71
- return `Read the error above for the specific cause. To (re)start the runtime, run: ${helper}`;
72
- }
73
-
74
- // has_arg: matches an exact flag or its `flag=value` form.
75
- function hasArg(args: string[], needle: string): boolean {
76
- return args.some((arg) => arg === needle || arg.startsWith(`${needle}=`));
77
- }
78
-
79
- // arg_value: value for `flag value` or `flag=value`, else undefined.
80
- function argValue(args: string[], needle: string): string | undefined {
81
- for (let i = 0; i < args.length; i += 1) {
82
- if (args[i] === needle) return args[i + 1];
83
- if (args[i].startsWith(`${needle}=`)) return args[i].slice(needle.length + 1);
84
- }
85
- return undefined;
86
- }
87
-
88
- function shellQuote(value: string): string {
89
- return /^[A-Za-z0-9_./:=@+-]+$/u.test(value) ? value : `'${value.replace(/'/gu, `'\\''`)}'`;
90
- }
91
-
92
- function isAdapter(value: string | undefined): value is MetaMaskRecipeAdapter {
93
- return value === 'mobile' || value === 'extension' || value === 'core';
94
- }
95
-
96
- interface ParsedHarnessArgs {
97
- // Kept as raw string so invalid values (e.g. --adapter garbage) are not silently
98
- // cast to MetaMaskRecipeAdapter; narrowed via isAdapter() at the validation site.
99
- adapter?: string;
100
- json: boolean;
101
- forward: string[];
102
- }
103
-
104
- // Split the adapter selector and --json out of the remaining argv so they are not
105
- // forwarded to the orchestration script (the .sh/.mjs scripts reject unknown
106
- // flags; Mobile's live/verify use --platform ios|android, so only the first
107
- // --platform whose value is an adapter is consumed here and a later --platform is
108
- // passed through). `--` forces everything after it to passthrough.
109
- function parseHarnessArgs(args: string[]): ParsedHarnessArgs {
110
- const forward: string[] = [];
111
- let adapter: string | undefined;
112
- let json = false;
113
- let separator = false;
114
- for (let i = 0; i < args.length; i += 1) {
115
- const arg = args[i];
116
- // POSIX: everything after `--` is literal; check separator FIRST so that
117
- // --json appearing after `--` is forwarded verbatim to the delegate.
118
- if (separator) {
119
- forward.push(arg);
120
- continue;
121
- }
122
- if (arg === '--') {
123
- separator = true;
124
- continue;
125
- }
126
- if (arg === '--json') {
127
- json = true;
128
- continue;
129
- }
130
- if (arg === '--adapter' || arg === '--platform') {
131
- const value = args[i + 1];
132
- if (arg === '--adapter') {
133
- adapter = value;
134
- i += 1;
135
- continue;
136
- }
137
- if (adapter === undefined && isAdapter(value)) {
138
- adapter = value;
139
- i += 1;
140
- continue;
141
- }
142
- forward.push(arg);
143
- continue;
144
- }
145
- if (arg.startsWith('--adapter=')) {
146
- adapter = arg.slice('--adapter='.length);
147
- continue;
148
- }
149
- if (arg.startsWith('--platform=')) {
150
- const value = arg.slice('--platform='.length);
151
- if (adapter === undefined && isAdapter(value)) {
152
- adapter = value;
153
- continue;
154
- }
155
- forward.push(arg);
156
- continue;
157
- }
158
- forward.push(arg);
159
- }
160
- return { adapter, json, forward };
161
- }
162
-
163
- // Mirror of scripts/lib/cli-common.sh detect_adapter for the auto-detect path.
164
- // Exported so `doctor` shares the exact same detect-from-target logic as the
165
- // overlay commands (verify/install/cleanup) — one auto-detect story, no drift.
166
- // Both escapes from a failed repo-type detection: where to run it (checkout/target)
167
- // and how to force it (adapter). A single source so every detect-failure teaches
168
- // the same reachable next step.
169
- export const ADAPTER_DETECT_NEXT =
170
- 'cd into a MetaMask checkout or pass --target <path>, or force it with --adapter <mobile|extension|core>';
171
-
172
- export function detectAdapter(target: string): MetaMaskRecipeAdapter | undefined {
173
- let remote = '';
174
- try {
175
- remote = execFileSync('git', ['-C', target, 'config', '--get', 'remote.origin.url'], {
176
- encoding: 'utf8',
177
- stdio: ['ignore', 'pipe', 'ignore'],
178
- }).trim();
179
- } catch {
180
- remote = '';
181
- }
182
- if (remote.includes('metamask-extension')) return 'extension';
183
- if (remote.includes('metamask-mobile')) return 'mobile';
184
-
185
- const exists = (rel: string): boolean => fs.existsSync(path.join(target, rel));
186
- const isDir = (rel: string): boolean => {
187
- try {
188
- return fs.statSync(path.join(target, rel)).isDirectory();
189
- } catch {
190
- return false;
191
- }
192
- };
193
-
194
- if (exists('development/skills-sync.ts') || (isDir('ui') && isDir('app/scripts'))) return 'extension';
195
- if (exists('scripts/skills-sync.mts') || (isDir('ios') && isDir('android') && isDir('app/core'))) return 'mobile';
196
- if (isDir('packages/perps-controller') && exists('yarn.lock')) return 'core';
197
- return undefined;
198
- }
199
-
200
- // Resolve the agentic-runtime.json path for a checkout: the RECIPE_RUNTIME_CONTEXT
201
- // override wins, else the per-checkout runtime dir. Single source shared by the
202
- // extension pre-dispatch hydration and doctor's runtime-context report.
203
- export function resolveRuntimeContextPath(target: string): string {
204
- return process.env.RECIPE_RUNTIME_CONTEXT ?? path.join(target, recipeRuntimeDir(), 'agentic-runtime.json');
205
- }
206
-
207
- // read_runtime_context_field: dotted lookup returning the value only when it is a
208
- // present, non-empty scalar; undefined when the file/field is missing.
209
- export function readRuntimeContextField(contextPath: string, field: string): string | undefined {
210
- let data: unknown;
211
- try {
212
- data = JSON.parse(fs.readFileSync(contextPath, 'utf8'));
213
- } catch {
214
- return undefined;
215
- }
216
- let node: unknown = data;
217
- for (const key of field.split('.')) {
218
- if (node === undefined || node === null || typeof node !== 'object') return undefined;
219
- node = (node as Record<string, unknown>)[key];
220
- }
221
- if (node === undefined || node === null || node === '') return undefined;
222
- return String(node);
223
- }
224
-
225
- // Port of recipe-harness.sh's extension pre-dispatch block: hydrate runtime env
226
- // from agentic-runtime.json and append --cdp-port / --prepare-cmd to the args
227
- // exactly as the skill did (extension launch/live/verify; here live/verify).
228
- function applyExtensionRuntimeEnv(target: string, action: HarnessAction, args: string[]): string[] {
229
- const contextPath = resolveRuntimeContextPath(target);
230
- const contextExists = fs.existsSync(contextPath);
231
-
232
- if (contextExists) {
233
- process.env.RECIPE_RUNTIME_CONTEXT = contextPath;
234
- if (!process.env.RECIPE_SLOT_ID) {
235
- const slotId = readRuntimeContextField(contextPath, 'slotId');
236
- if (slotId) process.env.RECIPE_SLOT_ID = slotId;
237
- }
238
- if (!process.env.RECIPE_HARNESS_EXTENSION_ID) {
239
- const extensionId = readRuntimeContextField(contextPath, 'extensionId');
240
- if (extensionId) process.env.RECIPE_HARNESS_EXTENSION_ID = extensionId;
241
- }
242
- if (process.env.RECIPE_RUNTIME_START_APPROVED === undefined) {
243
- const approved = readRuntimeContextField(contextPath, 'runtimeStart.approved');
244
- if (approved === 'true' || approved === 'True' || approved === '1') process.env.RECIPE_RUNTIME_START_APPROVED = '1';
245
- else if (approved === 'false' || approved === 'False' || approved === '0') process.env.RECIPE_RUNTIME_START_APPROVED = '0';
246
- }
247
- if (!process.env.RECIPE_RUNTIME_START_CMD) {
248
- const command = readRuntimeContextField(contextPath, 'runtimeStart.command');
249
- if (command) process.env.RECIPE_RUNTIME_START_CMD = command;
250
- }
251
- if (!process.env.RECIPE_RUNTIME_READY_URL) {
252
- const readyUrl = readRuntimeContextField(contextPath, 'runtimeStart.readyUrl');
253
- if (readyUrl) process.env.RECIPE_RUNTIME_READY_URL = readyUrl;
254
- }
255
- }
256
-
257
- let result = [...args];
258
- if (!hasArg(result, '--cdp-port')) {
259
- const contextPort = contextExists ? readRuntimeContextField(contextPath, 'cdpPort') : undefined;
260
- const cdpPort = contextPort ?? process.env.RECIPE_CDP_PORT ?? process.env.CDP_PORT;
261
- if (cdpPort) {
262
- process.env.RECIPE_CDP_PORT = cdpPort;
263
- process.env.CDP_PORT = cdpPort;
264
- result = [...result, '--cdp-port', cdpPort];
265
- }
266
- }
267
- if (action === 'live' && !hasArg(result, '--prepare-cmd')) {
268
- if (process.env.RECIPE_RUNTIME_START_APPROVED === '1' && process.env.RECIPE_RUNTIME_START_CMD) {
269
- result = [...result, '--prepare-cmd', process.env.RECIPE_RUNTIME_START_CMD];
270
- }
271
- }
272
- return result;
273
- }
274
-
275
- // runner_executable_entry / runner_file_entry: first candidate that exists (as an
276
- // executable, or as any file) wins; otherwise the first candidate is returned so
277
- // the exec error names it.
278
- function resolveEntry(base: string, candidates: string[], mode: 'exec' | 'file'): string {
279
- for (const candidate of candidates) {
280
- const full = path.join(base, candidate);
281
- try {
282
- const stat = fs.statSync(full);
283
- if (!stat.isFile()) continue;
284
- if (mode === 'file' || (stat.mode & 0o111) !== 0) return full;
285
- } catch {
286
- // missing candidate; try the next one
287
- }
288
- }
289
- return path.join(base, candidates[0]);
290
- }
291
-
292
- // Content of an installed overlay's runner source pointer, if present. Command
293
- // substitution in the skill stripped trailing newlines, so trim here too.
294
- // Validates the path exists so stale stamps (e.g. from a renamed package dir)
295
- // fall back to the current runnerDir rather than producing ENOENT at cleanup.
296
- function installedRunnerSource(target: string, adapter: MetaMaskRecipeAdapter): string | undefined {
297
- const pointer = path.join(recipeHarnessPath(target, adapter), 'runner', '.runner-source');
298
- if (!fs.existsSync(pointer)) return undefined;
299
- const value = fs.readFileSync(pointer, 'utf8').trim();
300
- if (!value || !fs.existsSync(value)) return undefined;
301
- return value;
302
- }
303
-
304
- function isExecutable(file: string): boolean {
305
- try {
306
- const stat = fs.statSync(file);
307
- return stat.isFile() && (stat.mode & 0o111) !== 0;
308
- } catch {
309
- return false;
310
- }
311
- }
312
-
313
- interface HarnessDispatch {
314
- command: string;
315
- prefixArgs: string[];
316
- }
317
-
318
- const INJECT_CANDIDATES: Record<MetaMaskRecipeAdapter, { entry: string; fallback: string }> = {
319
- mobile: { entry: 'adapters/mobile/inject.sh', fallback: 'scripts/inject-mobile-harness.sh' },
320
- extension: { entry: 'adapters/extension/inject.mjs', fallback: 'scripts/inject-extension-harness.mjs' },
321
- core: { entry: 'adapters/core/inject.sh', fallback: 'scripts/inject-core-harness.sh' },
322
- };
323
-
324
- const CLEANUP_CANDIDATES: Record<MetaMaskRecipeAdapter, { entry: string; fallback: string }> = {
325
- mobile: { entry: 'adapters/mobile/cleanup.sh', fallback: 'scripts/cleanup-mobile-harness.sh' },
326
- extension: { entry: 'adapters/extension/cleanup.mjs', fallback: 'scripts/cleanup-extension-harness.mjs' },
327
- core: { entry: 'adapters/core/cleanup.sh', fallback: 'scripts/cleanup-core-harness.sh' },
328
- };
329
-
330
- // Resolve the exact command + fixed prefix args to exec, mirroring
331
- // recipe-harness.sh dispatch_adapter_action. forwardArgs are appended by the
332
- // caller. Returns a string message for the one fail-fast case (missing core
333
- // delegate) that the skill handled inline.
334
- function resolveHarnessDispatch(
335
- adapter: MetaMaskRecipeAdapter,
336
- action: HarnessAction,
337
- target: string,
338
- ): HarnessDispatch | { error: string } {
339
- if (action === 'install') {
340
- const { entry, fallback } = INJECT_CANDIDATES[adapter];
341
- if (adapter === 'extension') {
342
- return { command: process.execPath, prefixArgs: [resolveEntry(runnerDir, [entry, fallback], 'file')] };
343
- }
344
- // Shell leaves are invoked through bash; file existence is the only requirement.
345
- return { command: resolveEntry(runnerDir, [entry, fallback], 'file'), prefixArgs: [] };
346
- }
347
-
348
- if (action === 'cleanup') {
349
- const { entry, fallback } = CLEANUP_CANDIDATES[adapter];
350
- const base = installedRunnerSource(target, adapter) ?? runnerDir;
351
- if (adapter === 'extension') {
352
- return { command: process.execPath, prefixArgs: [resolveEntry(base, [entry, fallback], 'file')] };
353
- }
354
- return { command: resolveEntry(base, [entry, fallback], 'file'), prefixArgs: [] };
355
- }
356
-
357
- if (adapter === 'core' && action === 'verify') {
358
- const delegate = path.join(recipeHarnessPath(target, 'core'), 'runner', 'bin', 'mm-harness');
359
- if (!isExecutable(delegate)) {
360
- return {
361
- error:
362
- `core recipe harness not installed: missing delegate ${delegate}.\n` +
363
- `Next: mm-harness install --platform core --target ${target}`,
364
- };
365
- }
366
- // --target is carried in forwardArgs (injected when the caller omits it), so
367
- // the delegate still receives `doctor --adapter core --target <t>`.
368
- return { command: delegate, prefixArgs: ['doctor', '--adapter', 'core'] };
369
- }
370
-
371
- // mobile/extension live|verify: prefer the installed helper (any mode), else
372
- // the runner's own adapters/scripts candidate. Bash invocation makes +x irrelevant.
373
- const installedScript = path.join(recipeHarnessPath(target, adapter), 'scripts', `${action}.sh`);
374
- if (fs.existsSync(installedScript)) {
375
- return { command: installedScript, prefixArgs: [] };
376
- }
377
- const command = resolveEntry(
378
- runnerDir,
379
- [`adapters/${adapter}/${action}.sh`, `scripts/${adapter}/${action}.sh`],
380
- 'file',
381
- );
382
- return { command, prefixArgs: [] };
383
- }
384
-
385
- // Mobile live: prepare device in-process then run the verify leaf.
386
- // Replaces the retired live.sh → launch.sh → mm-harness-mobile-prepare chain.
387
- // Control flows down only: TS calls prepareMobile(), then spawns verify.sh.
388
- async function handleMobileLive(
389
- target: string,
390
- forwardArgs: string[],
391
- json: boolean,
392
- autoDetected: boolean,
393
- ): Promise<number> {
394
- const platform = argValue(forwardArgs, '--platform') ?? 'ios';
395
- const watcherPortStr = argValue(forwardArgs, '--watcher-port') ?? process.env.WATCHER_PORT;
396
- const watcherPort = watcherPortStr ? parseInt(watcherPortStr, 10) : undefined;
397
- const start = Date.now();
398
-
399
- if (!json) {
400
- const detected = autoDetected ? ', auto-detected' : '';
401
- console.error(`→ live (mobile${detected}) — target: ${target}`);
402
- }
403
-
404
- const prepResult = await prepareMobile(target, { platform, json, watcherPort });
405
- if (prepResult.status !== 0) {
406
- const elapsed = ((Date.now() - start) / 1000).toFixed(1);
407
- if (json) {
408
- console.log(
409
- harnessSummary('live', 'mobile', target, 'fail', prepResult.status, autoDetected, {
410
- code: 'MOBILE_PREPARE_FAILED',
411
- message: `mobile prepare failed (exit ${prepResult.status})`,
412
- userAction: failureHint('mobile', 'live'),
413
- }),
414
- );
415
- } else {
416
- console.error(
417
- `✗ live mobile failed (exit ${prepResult.status}, ${elapsed}s)\n ${failureHint('mobile', 'live')}`,
418
- );
419
- }
420
- return prepResult.status;
421
- }
422
-
423
- // Verify leaf: --no-auto-start because launch already started the app.
424
- const installedVerify = path.join(recipeHarnessPath(target, 'mobile'), 'scripts', 'verify.sh');
425
- const verifySh = fs.existsSync(installedVerify)
426
- ? installedVerify
427
- : resolveEntry(runnerDir, ['adapters/mobile/verify.sh', 'scripts/mobile/verify.sh'], 'file');
428
-
429
- const verifyArgs = hasArg(forwardArgs, '--no-auto-start')
430
- ? [...forwardArgs]
431
- : ['--no-auto-start', ...forwardArgs];
432
-
433
- if (shellLeafMissing(verifySh)) {
434
- const message = missingShellLeafMessage(verifySh);
435
- if (json) {
436
- console.log(
437
- harnessSummary('live', 'mobile', target, 'fail', 1, autoDetected, {
438
- code: 'HARNESS_SPAWN_FAILED',
439
- message,
440
- userAction: failureHint('mobile', 'live'),
441
- }),
442
- );
443
- } else {
444
- console.error(`✗ ${message}`);
445
- }
446
- return 1;
447
- }
448
-
449
- const { bin: verifyBin, args: verifySpawnArgs } = resolveLeafInvoke(verifySh, verifyArgs);
450
- const result = spawnSync(verifyBin, verifySpawnArgs, {
451
- stdio: json ? ['inherit', 2, 'inherit'] : 'inherit',
452
- env: process.env,
453
- });
454
- const elapsed = ((Date.now() - start) / 1000).toFixed(1);
455
- const exitCode = result.status ?? 1;
456
-
457
- if (json) {
458
- console.log(
459
- harnessSummary(
460
- 'live',
461
- 'mobile',
462
- target,
463
- exitCode === 0 ? 'pass' : 'fail',
464
- exitCode,
465
- autoDetected,
466
- exitCode === 0
467
- ? undefined
468
- : { code: 'MOBILE_VERIFY_FAILED', message: `mobile live verify failed (exit ${exitCode})`, userAction: failureHint('mobile', 'live') },
469
- ),
470
- );
471
- } else if (exitCode === 0) {
472
- console.error(`✓ live mobile passed (${elapsed}s)`);
473
- } else {
474
- console.error(
475
- `✗ live mobile failed (exit ${exitCode}, ${elapsed}s)\n ${failureHint('mobile', 'live')}`,
476
- );
477
- }
478
- return exitCode;
479
- }
480
-
481
- export async function handleHarness(argv: string[]): Promise<number> {
482
- const action = argv[0];
483
- if (!action || action === '-h' || action === '--help') {
484
- harnessUsage();
485
- return action ? 0 : 2;
486
- }
487
- if (!HARNESS_ACTIONS.includes(action as HarnessAction)) {
488
- harnessUsage();
489
- console.error(`unsupported harness subcommand: ${action}`);
490
- return 2;
491
- }
492
- const harnessAction = action as HarnessAction;
493
-
494
- const { adapter: parsedAdapter, json, forward } = parseHarnessArgs(argv.slice(1));
495
- const rawTarget = argValue(forward, '--target');
496
- const target = path.resolve(rawTarget ?? process.cwd());
497
-
498
- const adapter = parsedAdapter ?? detectAdapter(target);
499
- // isAdapter() narrows adapter to MetaMaskRecipeAdapter for all code below.
500
- if (!adapter || !isAdapter(adapter)) {
501
- if (json) {
502
- // parsedAdapter is string|undefined here; not yet validated as a real
503
- // adapter, so pass undefined rather than forwarding a garbage string.
504
- const detectError = adapter
505
- ? { code: 'UNSUPPORTED_PLATFORM', message: `unsupported platform: ${adapter}`, userAction: 'pass --adapter <mobile|extension|core> to specify a supported adapter' }
506
- : { code: 'ADAPTER_DETECTION_FAILED', message: `could not detect the MetaMask repo type for ${target}`, userAction: ADAPTER_DETECT_NEXT };
507
- console.log(harnessSummary(harnessAction, undefined, target, 'fail', 2, false, detectError));
508
- } else {
509
- harnessUsage();
510
- console.error(
511
- adapter
512
- ? `\n✗ unsupported platform: ${adapter}`
513
- : `\n✗ could not detect the MetaMask repo type for ${target}\n Next: ${ADAPTER_DETECT_NEXT}`,
514
- );
515
- }
516
- return 2;
517
- }
518
- if (!isValidAdapterAction(adapter, harnessAction)) {
519
- if (json) {
520
- console.log(
521
- harnessSummary(harnessAction, adapter, target, 'fail', 2, parsedAdapter === undefined, {
522
- code: 'UNSUPPORTED_ACTION',
523
- message: `${harnessAction} is not supported for ${adapter} (core supports install, verify, cleanup).`,
524
- userAction: 'run mm-harness doctor to see which actions are supported for this adapter',
525
- }),
526
- );
527
- } else {
528
- console.error(
529
- `✗ ${harnessAction} is not supported for ${adapter} (core supports install, verify, cleanup).`,
530
- );
531
- }
532
- return 2;
533
- }
534
- const autoDetected = parsedAdapter === undefined;
535
-
536
- // Forward args carry --target so the orchestration script resolves the same
537
- // checkout; inject the resolved path only when the caller omitted it.
538
- let forwardArgs = hasArg(forward, '--target') ? [...forward] : ['--target', target, ...forward];
539
-
540
- if (harnessAction === 'provision') {
541
- return handleRunwayInstall(adapter, target, provisionRunwayForward(forward), json, 'provision');
542
- }
543
-
544
- if (harnessAction === 'install' && hasArg(forward, '--runway')) {
545
- return handleRunwayInstall(adapter, target, forward, json, 'install');
546
- }
547
-
548
- if (adapter === 'extension' && (harnessAction === 'live' || harnessAction === 'verify')) {
549
- forwardArgs = applyExtensionRuntimeEnv(target, harnessAction, forwardArgs);
550
- }
551
-
552
- // Mobile live is orchestrated in-process (prepareMobile → verify.sh leaf).
553
- if (adapter === 'mobile' && harnessAction === 'live') {
554
- return handleMobileLive(target, forwardArgs, json, autoDetected);
555
- }
556
-
557
- const dispatch = resolveHarnessDispatch(adapter, harnessAction, target);
558
- if ('error' in dispatch) {
559
- if (json) {
560
- console.log(
561
- harnessSummary(harnessAction, adapter, target, 'fail', 1, autoDetected, {
562
- code: 'DISPATCH_UNAVAILABLE',
563
- message: dispatch.error,
564
- // The dispatch error embeds "Next: <hint>" — extract it so --json consumers
565
- // get a clean programmatic escape without parsing the human message.
566
- userAction: dispatch.error.split('\nNext: ')[1] ?? 'run mm-harness install to complete setup, then retry',
567
- }),
568
- );
569
- } else {
570
- console.error(`✗ ${harnessAction} ${adapter} failed\n ${dispatch.error.replace(/\n/gu, '\n ')}`);
571
- }
572
- return 1;
573
- }
574
-
575
- if (!json) {
576
- const detected = autoDetected ? ', auto-detected' : '';
577
- console.error(`→ ${harnessAction} (${adapter}${detected}) — target: ${target}`);
578
- }
579
-
580
- const start = Date.now();
581
-
582
- // Shell leaves need bash invocation; bash exits 127 (not ENOENT) for missing files.
583
- if (shellLeafMissing(dispatch.command)) {
584
- const message = missingShellLeafMessage(dispatch.command);
585
- if (json) {
586
- console.log(
587
- harnessSummary(harnessAction, adapter, target, 'fail', 1, autoDetected, {
588
- code: 'HARNESS_SPAWN_FAILED',
589
- message,
590
- userAction: failureHint(adapter, harnessAction),
591
- }),
592
- );
593
- } else {
594
- console.error(`✗ ${message}`);
595
- }
596
- return 1;
597
- }
598
-
599
- const { bin: dispatchBin, args: dispatchArgs } = resolveLeafInvoke(dispatch.command, [
600
- ...dispatch.prefixArgs,
601
- ...forwardArgs,
602
- ]);
603
- const result = spawnSync(dispatchBin, dispatchArgs, {
604
- // Default: stream child output verbatim (byte-identical to the skill path).
605
- // --json: route child output to our stderr so stdout carries only the summary.
606
- stdio: json ? ['inherit', 2, 'inherit'] : 'inherit',
607
- env: process.env,
608
- });
609
- const seconds = ((Date.now() - start) / 1000).toFixed(1);
610
-
611
- if (result.error) {
612
- if (json) {
613
- console.log(
614
- harnessSummary(harnessAction, adapter, target, 'fail', 1, autoDetected, {
615
- code: 'HARNESS_SPAWN_FAILED',
616
- message: `${harnessAction} ${adapter} could not start: ${result.error.message}`,
617
- userAction: failureHint(adapter, harnessAction),
618
- }),
619
- );
620
- } else {
621
- console.error(`✗ ${harnessAction} ${adapter} could not start: ${result.error.message}\n ${failureHint(adapter, harnessAction)}`);
622
- }
623
- return 1;
624
- }
625
-
626
- const exitCode = result.status ?? 1;
627
- if (json) {
628
- console.log(
629
- harnessSummary(
630
- harnessAction,
631
- adapter,
632
- target,
633
- exitCode === 0 ? 'pass' : 'fail',
634
- exitCode,
635
- autoDetected,
636
- exitCode === 0
637
- ? undefined
638
- : { code: 'HARNESS_FAILED', message: `${harnessAction} ${adapter} failed (exit ${exitCode})`, userAction: failureHint(adapter, harnessAction) },
639
- ),
640
- );
641
- } else if (exitCode === 0) {
642
- console.error(`✓ ${harnessAction} ${adapter} passed (${seconds}s)`);
643
- } else {
644
- console.error(`✗ ${harnessAction} ${adapter} failed (exit ${exitCode}, ${seconds}s)\n ${failureHint(adapter, harnessAction)}`);
645
- }
646
- return exitCode;
647
- }
648
-
649
- async function handleRunwayInstall(
650
- adapter: MetaMaskRecipeAdapter,
651
- target: string,
652
- forward: string[],
653
- json: boolean,
654
- action: 'install' | 'provision',
655
- ): Promise<number> {
656
- const { getAdapterSurface } = await import('./adapters/surface.ts');
657
- const rerunCommand = action === 'provision'
658
- ? runwayProvisionRerunCommand(adapter, target, forward, json)
659
- : runwayInstallRerunCommand(adapter, target, forward, json);
660
- const surface = getAdapterSurface(adapter);
661
- const runtimeDir = argValue(forward, '--runtime-dir');
662
- const watcherPort = argValue(forward, '--watcher-port');
663
- const result = await surface.runwayProvision.run(target, {
664
- json,
665
- platform: argValue(forward, '--platform') ?? 'ios',
666
- branch: argValue(forward, '--branch'),
667
- defaultBranch: argValue(forward, '--default-branch'),
668
- run: argValue(forward, '--run'),
669
- cacheRoot: argValue(forward, '--cache-root'),
670
- simulator: argValue(forward, '--simulator') ?? argValue(forward, '--device'),
671
- runtime: argValue(forward, '--runtime'),
672
- deviceType: argValue(forward, '--device-type'),
673
- slot: argValue(forward, '--slot'),
674
- watcherPort,
675
- runtimeDir,
676
- force: hasArg(forward, '--force'),
677
- resolveOnly: hasArg(forward, '--resolve-only'),
678
- rerunCommand,
679
- });
680
- if (json) {
681
- console.log(JSON.stringify(result, null, 2));
682
- } else if (result.status === 'pass') {
683
- const cache = typeof result.cache === 'object' && result.cache ? result.cache as Record<string, unknown> : undefined;
684
- const simulator = typeof result.simulator === 'object' && result.simulator ? result.simulator as Record<string, unknown> : undefined;
685
- const artifact = typeof result.artifact === 'object' && result.artifact ? result.artifact as Record<string, unknown> : undefined;
686
- if (result.resolveOnly) {
687
- console.error(`✓ resolved Runway app for ${adapter} ${result.platform ?? ''} run=${artifact?.runId ?? 'unknown'} revision=${artifact?.revision ?? 'unknown'} artifact=${artifact?.artifactName ?? 'unknown'}`);
688
- } else {
689
- const action = result.skipped ? 'already provisioned' : 'installed Runway app';
690
- console.error(`✓ ${action} for ${adapter} ${result.platform ?? ''} simulator=${simulator?.name ?? 'unknown'} cache=${cache?.status ?? 'skip'}`);
691
- }
692
- } else {
693
- const label = action === 'provision' ? 'provision' : 'install --runway';
694
- console.error(`✗ mm-harness ${label}: ${result.error?.message ?? 'runway install failed'}\n Next: ${result.error?.userAction ?? rerunCommand}`);
695
- }
696
- return result.exitCode;
697
- }
698
-
699
- function provisionRunwayForward(forward: string[]): string[] {
700
- const normalized: string[] = [];
701
- let index = 0;
702
- if (forward[index] === 'runway') index += 1;
703
- if (forward[index] && !forward[index].startsWith('-')) {
704
- if (!hasArg(forward, '--platform')) normalized.push('--platform', forward[index]);
705
- index += 1;
706
- }
707
- return [...normalized, ...forward.slice(index)];
708
- }
709
-
710
- function runwayInstallRerunCommand(
711
- adapter: MetaMaskRecipeAdapter,
712
- target: string,
713
- forward: string[],
714
- json: boolean,
715
- ): string {
716
- const parts = ['mm-harness', 'install', '--runway', '--adapter', adapter, '--target', shellQuote(target)];
717
- const valueFlags = [
718
- '--platform',
719
- '--branch',
720
- '--default-branch',
721
- '--run',
722
- '--cache-root',
723
- '--simulator',
724
- '--device',
725
- '--slot',
726
- '--watcher-port',
727
- '--runtime-dir',
728
- '--runtime',
729
- '--device-type',
730
- ];
731
- for (const flag of valueFlags) {
732
- const value = argValue(forward, flag);
733
- if (value) parts.push(flag, shellQuote(value));
734
- }
735
- if (hasArg(forward, '--force')) parts.push('--force');
736
- if (hasArg(forward, '--resolve-only')) parts.push('--resolve-only');
737
- if (json) parts.push('--json');
738
- return parts.join(' ');
739
- }
740
-
741
- function runwayProvisionRerunCommand(
742
- adapter: MetaMaskRecipeAdapter,
743
- target: string,
744
- forward: string[],
745
- json: boolean,
746
- ): string {
747
- const parts = ['mm-harness', 'provision', 'runway', shellQuote(argValue(forward, '--platform') ?? 'ios'), '--adapter', adapter, '--target', shellQuote(target)];
748
- const valueFlags = [
749
- '--branch',
750
- '--default-branch',
751
- '--run',
752
- '--cache-root',
753
- '--simulator',
754
- '--device',
755
- '--slot',
756
- '--watcher-port',
757
- '--runtime-dir',
758
- '--runtime',
759
- '--device-type',
760
- ];
761
- for (const flag of valueFlags) {
762
- const value = argValue(forward, flag);
763
- if (value) parts.push(flag, shellQuote(value));
764
- }
765
- if (hasArg(forward, '--force')) parts.push('--force');
766
- if (hasArg(forward, '--resolve-only')) parts.push('--resolve-only');
767
- if (json) parts.push('--json');
768
- return parts.join(' ');
769
- }
770
-
771
- // userAction is required whenever an error object is present so every --json
772
- // failure carries a machine-readable escape path — parallel to the `usageOut`
773
- // enforcement on the CLI layer. Omitting userAction is a compile-time error.
774
- function harnessSummary(
775
- action: HarnessAction,
776
- adapter: MetaMaskRecipeAdapter | undefined,
777
- target: string,
778
- status: 'pass' | 'fail',
779
- exitCode: number,
780
- autoDetected: boolean,
781
- error?: { code: string; message: string; userAction: string },
782
- ): string {
783
- return JSON.stringify({
784
- schemaVersion: 1,
785
- command: 'harness',
786
- action,
787
- adapter: adapter ?? null,
788
- target,
789
- autoDetected,
790
- status,
791
- exitCode,
792
- // Error contract: every --json failure carries a stable machine code + human
793
- // message (CLI-SPEC.md §5.1). userAction is included when present so callers
794
- // can surface the reachable escape without parsing the human message.
795
- ...(status === 'fail' && error ? { error } : {}),
796
- });
797
- }