@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
@@ -9,10 +9,12 @@ import fs from 'node:fs';
9
9
  import path from 'node:path';
10
10
 
11
11
  import { color } from '../cli-color.ts';
12
- import { handleHarness, readRuntimeContextField, resolveRuntimeContextPath } from '../harness.ts';
12
+ import { handleHarness } from '../harness.ts';
13
13
  import { recipeHarnessPath, recipeRuntimeDir, recipeRuntimePath, runnerDir } from '../paths.ts';
14
14
  import type { MetaMaskRecipeAdapter } from '../types.ts';
15
15
  import { prepareMobile } from '../adapters/mobile/prepare.ts';
16
+ import { getAdapterSurface } from '../adapters/surface.ts';
17
+ import { stopExtensionWatcher } from '../adapters/slot-ports.ts';
16
18
  import {
17
19
  ADAPTER_DETECT_NEXT,
18
20
  EXIT,
@@ -207,111 +209,6 @@ export async function handleLaunch(argv: string[]): Promise<number> {
207
209
  });
208
210
  }
209
211
 
210
- // Apply KEY=VALUE lines from slot resolution to process.env.
211
- // overwrite=true → pool match, always overrides existing env.
212
- // overwrite=false → formula match, only fills vars that are unset.
213
- function applyKVLines(output: string, overwrite: boolean): void {
214
- for (const line of output.split('\n')) {
215
- const m = /^([A-Z_]+)=(.+)$/u.exec(line.trim());
216
- if (!m) continue;
217
- const [, key, val] = m;
218
- switch (key) {
219
- case 'WATCHER_PORT':
220
- if (overwrite || !process.env['WATCHER_PORT']) {
221
- process.env['WATCHER_PORT'] = val;
222
- process.env['METRO_PORT'] = val;
223
- process.env['RECIPE_WATCHER_PORT'] = val;
224
- }
225
- break;
226
- case 'IOS_SIMULATOR':
227
- if (overwrite || !process.env['IOS_SIMULATOR']) process.env['IOS_SIMULATOR'] = val;
228
- break;
229
- case 'SLOT_ID':
230
- if (overwrite || !process.env['RECIPE_SLOT_ID']) process.env['RECIPE_SLOT_ID'] = val;
231
- break;
232
- case 'CDP_PORT':
233
- if (overwrite || !process.env['CDP_PORT']) {
234
- process.env['CDP_PORT'] = val;
235
- process.env['RECIPE_CDP_PORT'] = val;
236
- }
237
- break;
238
- }
239
- }
240
- }
241
-
242
- // Resolve mobile slot port/simulator: the slot context the orchestrator wrote
243
- // into the checkout wins first, then the farmslot pool (both overwrite env),
244
- // then the slot-suffix formula (only fills unset vars). Called before explicit
245
- // CLI flag overrides so flags always win at the top.
246
- export function resolveMobileSlotPorts(target: string): void {
247
- const resolveScript = path.join(runnerDir, 'adapters/shared/resolve-farmslot-ports.sh');
248
- try {
249
- // The checkout's own runtime context is authoritative — it names the exact
250
- // simulator/port this slot was prepared with, surviving pool renames.
251
- const ctxOut = execFileSync('bash', [
252
- '-c', `source "${resolveScript}" && resolve_mobile_runtime_context "${target}"`,
253
- ], { encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] });
254
- if (ctxOut.trim()) {
255
- applyKVLines(ctxOut, true);
256
- return;
257
- }
258
- } catch { /* no runtime context — fall through to pool */ }
259
- try {
260
- // Pool match always wins — overwrite whatever is in the environment.
261
- const poolOut = execFileSync('bash', [
262
- '-c', `source "${resolveScript}" && resolve_farmslot_ports_by_repo "${target}"`,
263
- ], { encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] });
264
- if (poolOut.trim()) {
265
- applyKVLines(poolOut, true);
266
- return;
267
- }
268
- } catch { /* no pool match — fall through to formula */ }
269
- try {
270
- // Formula match only fills unset vars (never overrides explicit env/pool).
271
- const defOut = execFileSync('bash', [
272
- '-c', `source "${resolveScript}" && resolve_mobile_slot_defaults "${target}"`,
273
- ], { encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] });
274
- if (defOut.trim()) applyKVLines(defOut, false);
275
- } catch { /* no slot suffix in dir name — stays at env defaults */ }
276
- }
277
-
278
- // Resolve extension slot ports the same way as mobile: the checkout's runtime
279
- // context first (cdpPort/devServerPort written by the orchestrator's prepare),
280
- // then the farmslot pool, then the directory-suffix formula (fills unset only).
281
- function resolveExtensionSlotPorts(target: string): void {
282
- // The prepared checkout's context OVERWRITES inherited env (same authority as
283
- // mobile's context/pool resolution): a stale CDP_PORT from the shell must not
284
- // hijack the slot's browser. Explicit CLI flags are applied after and win.
285
- const contextPath = resolveRuntimeContextPath(target);
286
- const cdp = readRuntimeContextField(contextPath, 'cdpPort');
287
- if (cdp) {
288
- process.env['CDP_PORT'] = cdp;
289
- process.env['RECIPE_CDP_PORT'] = cdp;
290
- }
291
- const dev = readRuntimeContextField(contextPath, 'devServerPort');
292
- if (dev) {
293
- process.env['WATCHER_PORT'] = dev;
294
- process.env['RECIPE_WATCHER_PORT'] = dev;
295
- }
296
- if (process.env['CDP_PORT']) return;
297
- const resolveScript = path.join(runnerDir, 'adapters/shared/resolve-farmslot-ports.sh');
298
- try {
299
- const poolOut = execFileSync('bash', [
300
- '-c', `source "${resolveScript}" && resolve_farmslot_ports_by_repo "${target}"`,
301
- ], { encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] });
302
- if (poolOut.trim()) {
303
- applyKVLines(poolOut, true);
304
- return;
305
- }
306
- } catch { /* no pool match — fall through to formula */ }
307
- try {
308
- const defOut = execFileSync('bash', [
309
- '-c', `source "${resolveScript}" && resolve_default_extension_ports "${target}"`,
310
- ], { encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] });
311
- if (defOut.trim()) applyKVLines(defOut, false);
312
- } catch { /* no slot suffix in dir name — stays at env defaults */ }
313
- }
314
-
315
212
  // Map the env-gap flags onto the env vars the leaf scripts read. For mobile, slot
316
213
  // port/simulator defaults are resolved first (pool-wins, then formula-fills-empty)
317
214
  // so that the harness respects slot isolation; explicit CLI flags applied below
@@ -325,9 +222,9 @@ function applyLaunchEnvOverrides(
325
222
  // Slot isolation: resolve ports/device from the checkout's own slot context,
326
223
  // the farmslot pool, or the directory-suffix formula BEFORE applying explicit
327
224
  // flags — extension needs this as much as mobile (CDP_PORT), so neither
328
- // adapter hard-fails on a value its slot already knows.
329
- if (adapter === 'mobile') resolveMobileSlotPorts(target);
330
- if (adapter === 'extension') resolveExtensionSlotPorts(target);
225
+ // adapter hard-fails on a value its slot already knows. The surface owns the
226
+ // per-platform resolution; this command never branches on adapter for it.
227
+ getAdapterSurface(adapter).resolveSlotPorts(target);
331
228
 
332
229
  const device = str(options, 'device');
333
230
  if (device && adapter === 'mobile') {
@@ -421,56 +318,10 @@ async function executeComposition(
421
318
  async function extensionRebuild(target: string, json: boolean): Promise<ScriptResult> {
422
319
  const runtimeDirRel = recipeRuntimeDir();
423
320
  const runtimeAbs = path.join(target, runtimeDirRel);
424
- const webpackPidFile = path.join(runtimeAbs, 'recipe-harness-webpack.pid');
425
321
  const rebuildLog = path.join(runtimeAbs, 'rebuild.log');
426
322
 
427
323
  // E1a: Kill harness-owned watcher via pid file, then ps-scan for orphans.
428
- try {
429
- const pid = fs.readFileSync(webpackPidFile, 'utf8').trim();
430
- if (/^\d+$/u.test(pid)) {
431
- try { process.kill(Number(pid), 'SIGTERM'); } catch { /* already dead */ }
432
- }
433
- fs.rmSync(webpackPidFile, { force: true });
434
- } catch { /* no pid file */ }
435
- // Scan for any remaining orphan webpack/yarn-start processes in this checkout.
436
- try {
437
- const psOut = execFileSync('ps', ['-axo', 'pid=,command='], { encoding: 'utf8' });
438
- const orphanPids: number[] = [];
439
- for (const line of psOut.split('\n')) {
440
- const match = /^\s*(\d+)\s+(.*)$/u.exec(line);
441
- if (!match) continue;
442
- const [, pidStr, cmd] = match;
443
- const isWatcher =
444
- cmd.includes('yarn start') ||
445
- cmd.includes('webpack --watch') ||
446
- cmd.includes('development/webpack/launch.ts --watch');
447
- if (!isWatcher) continue;
448
- if (cmd.includes(target)) {
449
- orphanPids.push(Number(pidStr));
450
- continue;
451
- }
452
- // lsof cwd fallback for processes that don't embed the path in argv.
453
- try {
454
- const cwd = execFileSync('lsof', ['-a', `-p${pidStr}`, '-dcwd', '-Fn'], {
455
- encoding: 'utf8',
456
- timeout: 2000,
457
- });
458
- if (cwd.split('\n').some((l) => l.startsWith('n') && l.slice(1) === target)) {
459
- orphanPids.push(Number(pidStr));
460
- }
461
- } catch { /* lsof unavailable or permission denied */ }
462
- }
463
- if (orphanPids.length > 0) {
464
- for (const pid of orphanPids) {
465
- try { process.kill(pid, 'SIGTERM'); } catch { /* already dead */ }
466
- }
467
- // Brief pause then force-kill survivors.
468
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 2000);
469
- for (const pid of orphanPids) {
470
- try { process.kill(pid, 'SIGKILL'); } catch { /* already dead */ }
471
- }
472
- }
473
- } catch { /* ps not available */ }
324
+ stopExtensionWatcher(target);
474
325
 
475
326
  // E1c: Clear the rebuild log (directory must exist for tee).
476
327
  fs.mkdirSync(path.dirname(rebuildLog), { recursive: true });
@@ -5,7 +5,8 @@
5
5
  import fs from 'node:fs';
6
6
  import path from 'node:path';
7
7
 
8
- import { recipeRuntimePath, runnerDir } from '../paths.ts';
8
+ import { runnerDir } from '../paths.ts';
9
+ import { getAdapterSurface } from '../adapters/surface.ts';
9
10
  import { ADAPTER_DETECT_NEXT, EXIT, flag, parseFlags, resolveAdapter, spawnScript, str, targetOf, usageOut } from './shared.ts';
10
11
 
11
12
  const LOGS_BOOLEANS = new Set(['full', 'json']);
@@ -19,13 +20,23 @@ export async function handleLogs(argv: string[]): Promise<number> {
19
20
  if (!adapter) {
20
21
  return usageOut(json, 'logs', `could not detect the MetaMask repo type for ${target}`, ADAPTER_DETECT_NEXT);
21
22
  }
22
- if (adapter === 'core') {
23
- return usageOut(json, 'logs', 'core is headless; there are no app/Metro logs.', 'mm-harness run <recipe> # run recipes against the headless core');
23
+ const surface = getAdapterSurface(adapter);
24
+ if (surface.headless) {
25
+ return usageOut(json, 'logs', 'core is headless; it has no dev server logs.', surface.hints.launch);
24
26
  }
25
27
 
26
- const source = str(options, 'source') ?? 'metro';
27
- if (source !== 'metro' && source !== 'app') {
28
- return usageOut(json, 'logs', '--source must be metro or app.', 'mm-harness logs --source metro or mm-harness logs --source app');
28
+ // Source selection is platform-scoped: the valid names and the default come
29
+ // from THIS adapter's dev-server logs (mobile: metro; extension: webpack), plus
30
+ // the app-log source. No adapter's vocabulary is hardcoded here every
31
+ // non-headless adapter provides at least one log source (headless core returned
32
+ // above), so the default is that adapter's first source.
33
+ const logSources = surface.logSources(target);
34
+ const sourceLabels = logSources.map((entry) => entry.label);
35
+ const defaultSource = sourceLabels[0];
36
+ const source = str(options, 'source') ?? defaultSource;
37
+ const validSources = [...sourceLabels, 'app'];
38
+ if (!validSources.includes(source)) {
39
+ return usageOut(json, 'logs', `--source must be one of: ${validSources.join(', ')}.`, `mm-harness logs --source ${defaultSource}`);
29
40
  }
30
41
 
31
42
  // Env-gap flag (docs/CLI-SPEC.md Part 4): --events sets the compact event count
@@ -39,16 +50,21 @@ export async function handleLogs(argv: string[]): Promise<number> {
39
50
  process.env.RECIPE_LOG_EVENTS = events;
40
51
  }
41
52
 
42
- // Nothing running → teaching error pointing at launch. The log file is the
43
- // signal that Metro/webpack has been started for this checkout.
44
- const logFile = recipeRuntimePath(target, adapter === 'mobile' ? 'metro.log' : 'webpack.log');
45
- if (!fs.existsSync(logFile)) {
46
- const launchHint = adapter === 'mobile' ? 'mm-harness launch ios' : 'mm-harness launch';
53
+ // Nothing running → teaching error pointing at launch. A dev-server log file is
54
+ // the signal that the dev server has been started for this checkout; the surface
55
+ // owns which files a platform writes (mobile: metro.log; extension: webpack +
56
+ // watcher + rebuild logs). A `--source` naming a specific dev-server log is
57
+ // preferred; otherwise the most-relevant existing candidate is tailed.
58
+ const requested = logSources.find((entry) => entry.label === source);
59
+ const ordered = requested ? [requested, ...logSources.filter((entry) => entry !== requested)] : logSources;
60
+ const logFile = ordered.find((entry) => fs.existsSync(entry.path))?.path;
61
+ if (!logFile) {
62
+ const names = logSources.map((entry) => path.basename(entry.path)).join(' / ');
47
63
  return usageOut(
48
64
  json,
49
65
  'logs',
50
- `nothing running for this checkout (no ${path.basename(logFile)}).`,
51
- launchHint,
66
+ `nothing running for this checkout (no ${names}).`,
67
+ surface.hints.launch,
52
68
  );
53
69
  }
54
70
 
package/src/harness.ts CHANGED
@@ -16,15 +16,15 @@ import { prepareMobile } from './adapters/mobile/prepare.ts';
16
16
  // core delegate, extension runtime-context env, and verbatim arg passthrough —
17
17
  // is reproduced exactly so the skill can become a thin caller.
18
18
 
19
- type HarnessAction = 'install' | 'verify' | 'cleanup' | 'live';
19
+ type HarnessAction = 'install' | 'provision' | 'verify' | 'cleanup' | 'live';
20
20
 
21
- const HARNESS_ACTIONS: readonly HarnessAction[] = ['install', 'verify', 'cleanup', 'live'];
21
+ const HARNESS_ACTIONS: readonly HarnessAction[] = ['install', 'provision', 'verify', 'cleanup', 'live'];
22
22
  const ADAPTERS: readonly MetaMaskRecipeAdapter[] = ['mobile', 'extension', 'core'];
23
23
 
24
24
  // Mirror of scripts/lib/cli-common.sh valid_adapter_action, restricted to the
25
25
  // subcommands this command exposes (core has no app/live lifecycle).
26
26
  function isValidAdapterAction(adapter: MetaMaskRecipeAdapter, action: HarnessAction): boolean {
27
- if (adapter === 'core') return action === 'install' || action === 'verify' || action === 'cleanup';
27
+ if (adapter === 'core') return action === 'install' || action === 'provision' || action === 'verify' || action === 'cleanup';
28
28
  return true;
29
29
  }
30
30
 
@@ -37,6 +37,8 @@ is auto-detected from the repo. Pass --platform only to override.
37
37
  Commands (one copy-pasteable example each):
38
38
  install Install the recipe harness runtime overlay into the checkout.
39
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
40
42
  verify Check the harness/runtime is present and healthy (no app launch).
41
43
  mm-harness verify
42
44
  cleanup Remove the installed harness overlay and restore the checkout.
@@ -83,6 +85,10 @@ function argValue(args: string[], needle: string): string | undefined {
83
85
  return undefined;
84
86
  }
85
87
 
88
+ function shellQuote(value: string): string {
89
+ return /^[A-Za-z0-9_./:=@+-]+$/u.test(value) ? value : `'${value.replace(/'/gu, `'\\''`)}'`;
90
+ }
91
+
86
92
  function isAdapter(value: string | undefined): value is MetaMaskRecipeAdapter {
87
93
  return value === 'mobile' || value === 'extension' || value === 'core';
88
94
  }
@@ -530,6 +536,15 @@ export async function handleHarness(argv: string[]): Promise<number> {
530
536
  // Forward args carry --target so the orchestration script resolves the same
531
537
  // checkout; inject the resolved path only when the caller omitted it.
532
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
+
533
548
  if (adapter === 'extension' && (harnessAction === 'live' || harnessAction === 'verify')) {
534
549
  forwardArgs = applyExtensionRuntimeEnv(target, harnessAction, forwardArgs);
535
550
  }
@@ -631,6 +646,128 @@ export async function handleHarness(argv: string[]): Promise<number> {
631
646
  return exitCode;
632
647
  }
633
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
+
634
771
  // userAction is required whenever an error object is present so every --json
635
772
  // failure carries a machine-readable escape path — parallel to the `usageOut`
636
773
  // enforcement on the CLI layer. Omitting userAction is a compile-time error.
@@ -61,16 +61,18 @@ Example:
61
61
  },
62
62
  {
63
63
  name: 'stop',
64
- summary: 'Stop the dev server this checkout owns (mobile: port-scoped Metro) and close its log window.',
64
+ summary: 'Stop the dev server this checkout owns (mobile Metro / extension webpack watcher) and close its log window.',
65
65
  example: 'mm-harness stop',
66
66
  helpText: `mm-harness stop [flags]
67
67
 
68
- Stop the Metro dev server this checkout owns and close its tmux log-tail window.
69
- Port-scoped: only the listener on the checkout's resolved port is signalled, so
70
- concurrent slots are untouched. Nothing running = success (idempotent).
71
- Extension/core: teaching error (their dev servers are slot-managed).
68
+ Stop the dev server this checkout owns and close its tmux log-tail window,
69
+ scoped to this checkout so concurrent slots are untouched. Idempotent
70
+ nothing running is success, not an error. Behavior is per platform:
71
+ mobile stop the port-scoped Metro dev server
72
+ extension stop the checkout's webpack watcher (pid file + orphan scan)
73
+ core headless — no dev server to stop (teaching error)
72
74
 
73
- --port <port> Metro port (default: the checkout's slot context)
75
+ --port <port> Dev-server port (default: the checkout's slot context)
74
76
  --target <path> Checkout path (default: cwd)
75
77
  --json Machine-readable output
76
78
 
@@ -81,11 +83,13 @@ Example:
81
83
  {
82
84
  name: 'call',
83
85
  summary: 'Run one action in isolation as a one-node recipe through the real engine path (fuzzy short names; --arg k=v; same trace/evidence as run).',
84
- example: 'mm-harness call unlock',
86
+ example: 'mm-harness call ensure_unlocked',
85
87
  helpText: `mm-harness call <action> [--arg k=v ...] [flags]
86
88
 
87
89
  Run one action in isolation as a one-node recipe through the real engine path.
88
- Fuzzy short-name: 'unlock' resolves to 'metamask.wallet.unlock' if unique; ambiguous = exit 2.
90
+ Fuzzy short-name: 'ensure_unlocked' resolves to 'metamask.wallet.ensure_unlocked'
91
+ if unique; ambiguous = exit 2. Actions differ per adapter — list this checkout's
92
+ with: mm-harness actions.
89
93
 
90
94
  --arg k=v Action field value (repeatable)
91
95
  --adapter <mobile|extension|core> Target adapter (auto-detected inside a checkout)
@@ -95,9 +99,9 @@ Example:
95
99
  --heal <off|infra-only|auto> Healing policy (default: infra-only); auto-ensures the overlay
96
100
  --json Machine-readable output
97
101
 
98
- Example:
99
- mm-harness call unlock --adapter extension
100
- mm-harness call command --arg cmd="echo hi" --adapter core --json`,
102
+ Example (real actions; run mm-harness actions for this checkout's full set):
103
+ mm-harness call ensure_unlocked --adapter extension # a wallet action (extension/mobile)
104
+ mm-harness call command --arg cmd="echo hi" --adapter core # the universal action (all adapters)`,
101
105
  },
102
106
  {
103
107
  name: 'flows',
@@ -155,21 +159,68 @@ Example:
155
159
  mm-harness doctor --fix --json
156
160
  mm-harness doctor --adapter mobile --target /path/to/checkout`,
157
161
  },
162
+ {
163
+ name: 'provision',
164
+ summary: 'Install the cached Runway iOS dev client on a prepared mobile slot (no deps, no Metro).',
165
+ example: 'mm-harness provision runway ios --adapter mobile',
166
+ helpText: `mm-harness provision [runway ios] [flags]
167
+
168
+ Install the cached Runway iOS dev client on the slot simulator. This is a thin
169
+ provisioning path only: artifact cache + simulator create + simctl install.
170
+ JavaScript dependencies and Metro remain dispatch-time launch concerns.
171
+
172
+ --adapter <mobile|extension|core> Target adapter (mobile supported; extension/core teach)
173
+ --target <path> Slot checkout path (default: cwd)
174
+ --platform <ios> Platform (default ios)
175
+ --simulator <name|udid> Override agentic-runtime.json simulator (alias: --device)
176
+ --device <name|udid> Alias for --simulator
177
+ --slot <id> Farm slot id recorded in the provision baseline
178
+ --watcher-port <port> Farm Metro/watcher port carried through context and Next:
179
+ --runtime-dir <dir> Runtime dir containing agentic-runtime.json (relative to target)
180
+ --runtime <id> iOS runtime id used if simulator must be created
181
+ --device-type <id> Device type id used if simulator must be created
182
+ --branch <ref> Probe this ref before default branch
183
+ --default-branch <ref> Fallback ref (default main)
184
+ --run <id> Exact GitHub Actions run id
185
+ --cache-root <dir> Override shared runway cache root
186
+ --force Reinstall even when the app is already present
187
+ --resolve-only Resolve artifact metadata only; no simulator/cache/install
188
+ --json Machine-readable envelope; progress stays stderr
189
+
190
+ Example:
191
+ mm-harness provision runway ios --adapter mobile --target /path/to/slot
192
+ mm-harness provision runway ios --adapter mobile --slot scratch-1 --runtime-dir temp/recipe/runtime-8081
193
+ mm-harness provision runway ios --run 28676856835 --resolve-only --json`,
194
+ },
158
195
  {
159
196
  name: 'install',
160
- summary: 'Install the per-checkout runtime overlay (CI/agents; the everyday commands auto-ensure it).',
197
+ summary: 'Install the per-checkout runtime overlay, or --runway to install a cached mobile dev client.',
161
198
  example: 'mm-harness install',
162
199
  helpText: `mm-harness install [flags]
163
200
 
164
201
  Install the per-checkout runtime overlay (for CI / agents).
165
- The everyday commands auto-ensure it on first use.
202
+ Add --runway on a mobile slot to install the cached Runway iOS dev client only:
203
+ artifact cache + simulator create + simctl install. JavaScript dependencies
204
+ and Metro remain dispatch-time launch concerns.
166
205
 
167
206
  --adapter <mobile|extension|core> Target adapter (auto-detected inside a checkout)
168
207
  --target <path> Checkout path (default: cwd)
208
+ --runway Thin mobile Runway artifact install instead of overlay install
209
+ --platform <ios> Runway platform (default ios)
210
+ --simulator <name|udid> Override agentic-runtime.json simulator
211
+ --runtime <id> iOS runtime id used if simulator must be created
212
+ --device-type <id> Device type id used if simulator must be created
213
+ --branch <ref> Probe this ref before default branch
214
+ --default-branch <ref> Fallback ref (default main)
215
+ --run <id> Exact GitHub Actions run id
216
+ --cache-root <dir> Override shared runway cache root
217
+ --force Reinstall even when the app is already present
218
+ --resolve-only Runway metadata only; no simulator/cache/install
169
219
 
170
220
  Example:
171
221
  mm-harness install
172
- mm-harness install --adapter extension --target /path/to/checkout`,
222
+ mm-harness install --adapter extension --target /path/to/checkout
223
+ mm-harness install --runway --adapter mobile --target /path/to/slot`,
173
224
  },
174
225
  {
175
226
  name: 'verify',
@@ -241,8 +292,10 @@ Example:
241
292
 
242
293
  --full Raw log tail (default = compact) (env: RECIPE_LOG_UI)
243
294
  --events <n> Compact event count (default 10) (env: RECIPE_LOG_EVENTS)
244
- --source <metro|app> Log source (default metro)
245
- --adapter <mobile|extension> Target adapter (auto-detected inside a checkout)
295
+ --source <label> Log source per adapter — mobile: metro|app (default metro);
296
+ extension: webpack|watcher|rebuild|app (default webpack).
297
+ Core is headless (teaching error).
298
+ --adapter <mobile|extension|core> Target adapter (auto-detected inside a checkout)
246
299
  --target <path> Checkout path (default: cwd)
247
300
  --json Machine-readable output
248
301
 
@@ -380,8 +433,8 @@ const HELP_GROUPS: HelpGroup[] = [
380
433
  },
381
434
  {
382
435
  title: 'RUNTIME OVERLAY',
383
- blurb: 'install/verify/clean the per-checkout overlay (the everyday commands auto-ensure it)',
384
- commands: ['install', 'verify', 'cleanup'],
436
+ blurb: 'install/verify/clean the overlay, plus thin mobile slot provisioning',
437
+ commands: ['provision', 'install', 'verify', 'cleanup'],
385
438
  },
386
439
  {
387
440
  title: 'MAINTAIN',