@deeeed/metamask-harness 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/adapters/core/surface.js +53 -0
  3. package/dist/adapters/extension/ensure-ready.js +109 -0
  4. package/dist/adapters/extension/extension-id.js +62 -0
  5. package/dist/adapters/extension/runtime-decision.js +305 -0
  6. package/dist/adapters/extension/runtime.js +324 -0
  7. package/dist/adapters/extension/surface.js +69 -0
  8. package/dist/adapters/mobile/deps-markers.js +22 -0
  9. package/dist/adapters/mobile/prepare.js +146 -0
  10. package/dist/adapters/mobile/provision.js +465 -0
  11. package/dist/adapters/mobile/runtime-decision.js +315 -0
  12. package/dist/adapters/mobile/surface.js +54 -0
  13. package/dist/adapters/slot-ports.js +146 -0
  14. package/dist/adapters/surface.js +14 -0
  15. package/dist/adapters.js +485 -0
  16. package/dist/cli-color.js +79 -0
  17. package/dist/cli-commands.js +224 -0
  18. package/dist/cli-version.js +111 -0
  19. package/dist/cli.js +1571 -0
  20. package/dist/commands/debug.js +56 -0
  21. package/dist/commands/fixtures.js +153 -0
  22. package/dist/commands/launch.js +325 -0
  23. package/dist/commands/logs.js +73 -0
  24. package/dist/commands/shared.js +157 -0
  25. package/dist/commands/update.js +243 -0
  26. package/dist/completions-cache.js +53 -0
  27. package/dist/doctor.js +169 -0
  28. package/dist/harness.js +627 -0
  29. package/dist/heal-bounds.js +120 -0
  30. package/dist/index.js +25 -0
  31. package/dist/leaf-invoke.js +19 -0
  32. package/dist/live-adapter-contract.js +240 -0
  33. package/dist/manifest.js +37 -0
  34. package/dist/mm-harness-cli.js +521 -0
  35. package/dist/paths.js +179 -0
  36. package/dist/progress.js +94 -0
  37. package/dist/recording-target.js +133 -0
  38. package/dist/run-recording.js +271 -0
  39. package/dist/runner.js +88 -0
  40. package/dist/types.js +0 -0
  41. package/docs/CLI-SPEC.md +26 -3
  42. package/package.json +5 -1
  43. package/src/adapters/core/surface.ts +15 -0
  44. package/src/adapters/extension/surface.ts +20 -3
  45. package/src/adapters/mobile/provision.ts +594 -0
  46. package/src/adapters/mobile/surface.ts +16 -4
  47. package/src/adapters/slot-ports.ts +1 -1
  48. package/src/adapters/surface.ts +35 -0
  49. package/src/cli-commands.ts +1 -1
  50. package/src/cli.ts +149 -6
  51. package/src/harness.ts +140 -3
  52. package/src/mm-harness-cli.ts +52 -5
@@ -33,7 +33,7 @@ export const SPEC: CommandSpecMap = {
33
33
  { name: 'logs', aliases: ['tail'], desc: 'Compact build events or full log', flags: ['--full', '-f'] },
34
34
  { name: 'debug', aliases: ['devtools', 'inspect'], desc: 'Open DevTools UI', flags: ['--json', '--no-open'] },
35
35
  { name: 'actions', desc: 'List runnable recipe actions', flags: ['--json'] },
36
- { name: 'doctor', desc: 'Check harness/orchestration health', flags: ['--json'] },
36
+ { name: 'doctor', desc: 'Check harness/orchestration health', flags: ['--json', '--target', '--adapter', '--runtime-dir'] },
37
37
  { name: 'run', desc: 'Execute a proof recipe', args: ['recipe.json'] },
38
38
  { name: 'interactive', aliases: ['menu'], desc: 'Interactive command menu' },
39
39
  { name: 'prepare', desc: 'Install harness (+ optional validate)', flags: ['--target', '--runtime-dir', '--json'] },
package/src/cli.ts CHANGED
@@ -68,6 +68,7 @@ type CliOptions = Record<string, CliOptionValue>;
68
68
  interface ParsedArgs {
69
69
  positional: string[];
70
70
  options: CliOptions;
71
+ rawArgv: string[];
71
72
  }
72
73
 
73
74
  interface RuntimeOptions {
@@ -105,6 +106,7 @@ const COMMANDS: Record<string, (args: ParsedArgs) => Promise<number>> = {
105
106
  'runtime-health': handleRuntimeHealth,
106
107
  'runtime-decision': handleRuntimeDecision,
107
108
  'runtime-launch': handleRuntimeLaunch,
109
+ provision: handleProvision,
108
110
  'resolve-extension': handleResolveExtension,
109
111
  'ensure-ready': handleEnsureReady,
110
112
  run: handleRun,
@@ -114,7 +116,7 @@ const COMMANDS: Record<string, (args: ParsedArgs) => Promise<number>> = {
114
116
  // Top-level runtime-overlay commands (the tool installs a runtime overlay).
115
117
  // Top-level overlay commands route to handleHarness; the `harness <sub>`
116
118
  // subcommand form has been removed from the CLI surface.
117
- const OVERLAY_COMMANDS: readonly string[] = ['install', 'verify', 'cleanup', 'live'];
119
+ const OVERLAY_COMMANDS: readonly string[] = ['install', 'provision', 'verify', 'cleanup', 'live'];
118
120
 
119
121
  function usage() {
120
122
  // Help is organized by the harness mental model: mm-harness IS the tool; a
@@ -176,7 +178,7 @@ See docs/MENTAL-MODEL.md (overview) and docs/CLI-SPEC.md (full contract).
176
178
  function parseArgs(argv: string[], command?: string): ParsedArgs {
177
179
  const positional: string[] = [];
178
180
  const options: CliOptions = {};
179
- const booleanOptions = new Set(['json', 'launchExistingDist', 'startWatch', 'record', 'plan', 'raw', 'fix']);
181
+ const booleanOptions = new Set(['json', 'launchExistingDist', 'startWatch', 'record', 'plan', 'raw', 'fix', 'force', 'resolveOnly']);
180
182
  for (let i = 0; i < argv.length; i += 1) {
181
183
  const arg = argv[i];
182
184
  if (!arg.startsWith('--')) {
@@ -216,7 +218,7 @@ function parseArgs(argv: string[], command?: string): ParsedArgs {
216
218
  options[key] = argv[i + 1];
217
219
  i += 1;
218
220
  }
219
- return { positional, options };
221
+ return { positional, options, rawArgv: [...argv] };
220
222
  }
221
223
 
222
224
  function parseRecordVideoMode(value: string | undefined): false | 'full-run' {
@@ -249,6 +251,20 @@ function optionFlag(options: CliOptions, key: string): boolean {
249
251
  return value === true;
250
252
  }
251
253
 
254
+
255
+ function applyRuntimeDirOption(options: CliOptions): void {
256
+ const runtimeDir = optionString(options, 'runtimeDir');
257
+ if (runtimeDir) process.env.RECIPE_RUNTIME_DIR = runtimeDir;
258
+ }
259
+
260
+ function applyWatcherPortOption(options: CliOptions): void {
261
+ const watcherPort = optionString(options, 'watcherPort');
262
+ if (!watcherPort) return;
263
+ process.env.WATCHER_PORT = watcherPort;
264
+ process.env.RECIPE_WATCHER_PORT = watcherPort;
265
+ process.env.METRO_PORT = watcherPort;
266
+ }
267
+
252
268
  function requiredOption(options: CliOptions, key: string, message: string): string {
253
269
  const value = optionString(options, key);
254
270
  if (!value) throw usageError(message);
@@ -450,6 +466,7 @@ async function handleActions({ options }: ParsedArgs): Promise<number> {
450
466
  }
451
467
 
452
468
  async function handleDoctor({ options }: ParsedArgs): Promise<number> {
469
+ applyRuntimeDirOption(options);
453
470
  const target = targetPath(options);
454
471
  // Share the exact detect-from-target logic the overlay commands (verify/install/
455
472
  // cleanup) use: when --adapter/--platform is omitted, auto-detect from the
@@ -494,12 +511,17 @@ async function handleDoctor({ options }: ParsedArgs): Promise<number> {
494
511
  const stateStyle = (value: string | undefined, good: string) => (value === good ? 'ok' : 'warn');
495
512
  console.log(`${out(result.status === 'pass' ? 'ok' : 'err', result.status)} ${out('bold', adapter)} ${result.compatibilityMode} ${out('dim', `manifest=${actionManifestPath}`)}`);
496
513
  if (runtime) {
497
- const decisionStyle = runtime.decision === 'ready' ? 'ok' : runtime.decision === 'blocked' ? 'err' : 'warn';
514
+ const provisionedDepsPending = runtime.reasonCode === 'app-installed-deps-pending';
515
+ const decisionStyle = provisionedDepsPending
516
+ ? 'info'
517
+ : runtime.decision === 'ready' ? 'ok' : runtime.decision === 'blocked' ? 'err' : 'warn';
518
+ const depsStyle = provisionedDepsPending ? 'info' : stateStyle(runtime.deps, 'current');
519
+ const devServerStyle = provisionedDepsPending ? 'info' : undefined;
498
520
  const devServer = runtime.devServer
499
- ? ` ${runtime.devServer.label}=${out(stateStyle(runtime.devServer.status, 'up'), runtime.devServer.status)}`
521
+ ? ` ${runtime.devServer.label}=${out(devServerStyle ?? stateStyle(runtime.devServer.status, 'up'), runtime.devServer.status)}`
500
522
  : '';
501
523
  console.log(
502
- `${out('label', 'runtime:')} decision=${out(decisionStyle, runtime.decision)}${runtime.reasonCode ? ` ${out('dim', `(${runtime.reasonCode})`)}` : ''} deps=${out(stateStyle(runtime.deps, 'current'), runtime.deps ?? 'unknown')}${devServer}`,
524
+ `${out('label', 'runtime:')} decision=${out(decisionStyle, runtime.decision)}${runtime.reasonCode ? ` ${out('dim', `(${runtime.reasonCode})`)}` : ''} deps=${out(depsStyle, runtime.deps ?? 'unknown')}${devServer}`,
503
525
  );
504
526
  for (const reason of runtime.reasons) console.log(` ${out('dim', reason)}`);
505
527
  }
@@ -609,6 +631,126 @@ function isRecord(value: unknown): value is Record<string, unknown> {
609
631
  return typeof value === 'object' && value !== null && !Array.isArray(value);
610
632
  }
611
633
 
634
+ async function handleProvision({ positional, options, rawArgv }: ParsedArgs): Promise<number> {
635
+ applyWatcherPortOption(options);
636
+ const json = optionFlag(options, 'json');
637
+ const { adapter, target } = resolveProvisionAdapter(options);
638
+ const surface = getAdapterSurface(adapter);
639
+ const rerunCommand = provisionRerunCommand(rawArgv, options, adapter, target);
640
+ const result = await surface.runwayProvision.run(target, {
641
+ json,
642
+ platform: optionString(options, 'platform') ?? optionString(options, 'devicePlatform') ?? (positional[0] === 'runway' ? positional[1] : positional[0]) ?? 'ios',
643
+ branch: optionString(options, 'branch'),
644
+ defaultBranch: optionString(options, 'defaultBranch'),
645
+ run: optionString(options, 'run'),
646
+ cacheRoot: optionString(options, 'cacheRoot'),
647
+ simulator: optionString(options, 'simulator') ?? optionString(options, 'device'),
648
+ runtime: optionString(options, 'runtime'),
649
+ deviceType: optionString(options, 'deviceType'),
650
+ slot: optionString(options, 'slot'),
651
+ watcherPort: optionString(options, 'watcherPort'),
652
+ runtimeDir: optionString(options, 'runtimeDir'),
653
+ force: optionFlag(options, 'force'),
654
+ resolveOnly: optionFlag(options, 'resolveOnly'),
655
+ rerunCommand,
656
+ });
657
+ if (json) {
658
+ console.log(JSON.stringify(result, null, 2));
659
+ } else if (result.status === 'pass') {
660
+ const cache = typeof result.cache === 'object' && result.cache ? result.cache as Record<string, unknown> : undefined;
661
+ const simulator = typeof result.simulator === 'object' && result.simulator ? result.simulator as Record<string, unknown> : undefined;
662
+ const artifact = typeof result.artifact === 'object' && result.artifact ? result.artifact as Record<string, unknown> : undefined;
663
+ if (result.resolveOnly) {
664
+ console.error(`✓ resolved ${adapter} ${result.platform ?? ''} run=${artifact?.runId ?? 'unknown'} revision=${artifact?.revision ?? 'unknown'} artifact=${artifact?.artifactName ?? 'unknown'}`);
665
+ } else {
666
+ const action = result.skipped ? 'already provisioned' : 'provisioned';
667
+ console.error(`✓ ${action} ${adapter} ${result.platform ?? ''} simulator=${simulator?.name ?? 'unknown'} cache=${cache?.status ?? 'unknown'}`);
668
+ }
669
+ } else {
670
+ console.error(`✗ mm-harness provision: ${result.error?.message ?? 'provision failed'}\n Next: ${result.error?.userAction ?? rerunCommand}`);
671
+ }
672
+ return result.exitCode;
673
+ }
674
+
675
+ function resolveProvisionAdapter(options: CliOptions): { adapter: MetaMaskRecipeAdapter; target: string } {
676
+ const target = targetPath(options);
677
+ const adapter = optionString(options, 'adapter') ?? detectAdapter(target);
678
+ if (!adapter) {
679
+ throw usageError(`could not detect the MetaMask repo type for ${target}\n Next: ${ADAPTER_DETECT_NEXT}`);
680
+ }
681
+ try {
682
+ assertAdapter(adapter);
683
+ } catch (error) {
684
+ throw usageError(error instanceof Error ? error.message : String(error));
685
+ }
686
+ return { adapter, target };
687
+ }
688
+
689
+ function provisionRerunCommand(rawArgv: string[], options: CliOptions, adapter: MetaMaskRecipeAdapter, target: string): string {
690
+ const parts = ['mm-harness', 'provision'];
691
+ for (const positional of provisionPositionals(rawArgv)) parts.push(shellQuoteArg(positional));
692
+ parts.push('--adapter', adapter, '--target', shellQuote(target));
693
+ const aliases: Array<[string, string[]]> = [
694
+ ['platform', ['--platform', '--device-platform']],
695
+ ['branch', ['--branch']],
696
+ ['defaultBranch', ['--default-branch']],
697
+ ['run', ['--run']],
698
+ ['cacheRoot', ['--cache-root']],
699
+ ['simulator', ['--simulator', '--device']],
700
+ ['runtime', ['--runtime']],
701
+ ['deviceType', ['--device-type']],
702
+ ['slot', ['--slot']],
703
+ ['watcherPort', ['--watcher-port']],
704
+ ['runtimeDir', ['--runtime-dir']],
705
+ ];
706
+ for (const [key, flags] of aliases) {
707
+ const found = findRawOption(rawArgv, flags);
708
+ const value = found?.value ?? optionString(options, key);
709
+ if (value) parts.push(found?.flag ?? flags[0], shellQuoteArg(value));
710
+ }
711
+ if (optionFlag(options, 'force')) parts.push('--force');
712
+ if (optionFlag(options, 'resolveOnly')) parts.push('--resolve-only');
713
+ if (optionFlag(options, 'json')) parts.push('--json');
714
+ return parts.join(' ');
715
+ }
716
+
717
+ function provisionPositionals(rawArgv: string[]): string[] {
718
+ const positionals: string[] = [];
719
+ const valueFlags = new Set([
720
+ '--adapter', '--target', '--project-root', '--platform', '--device-platform', '--branch', '--default-branch',
721
+ '--run', '--cache-root', '--simulator', '--device', '--runtime', '--device-type', '--slot', '--watcher-port',
722
+ '--runtime-dir',
723
+ ]);
724
+ for (let i = 0; i < rawArgv.length; i += 1) {
725
+ const arg = rawArgv[i];
726
+ if (!arg.startsWith('--')) {
727
+ positionals.push(arg);
728
+ continue;
729
+ }
730
+ const key = arg.includes('=') ? arg.slice(0, arg.indexOf('=')) : arg;
731
+ if (!arg.includes('=') && valueFlags.has(key)) i += 1;
732
+ }
733
+ return positionals;
734
+ }
735
+
736
+ function findRawOption(rawArgv: string[], flags: string[]): { flag: string; value: string } | undefined {
737
+ for (let i = 0; i < rawArgv.length; i += 1) {
738
+ const arg = rawArgv[i];
739
+ for (const flag of flags) {
740
+ if (arg === flag) {
741
+ const value = rawArgv[i + 1];
742
+ return value && !value.startsWith('--') ? { flag, value } : undefined;
743
+ }
744
+ if (arg.startsWith(`${flag}=`)) return { flag, value: arg.slice(flag.length + 1) };
745
+ }
746
+ }
747
+ return undefined;
748
+ }
749
+
750
+ function shellQuoteArg(value: string): string {
751
+ return /^[A-Za-z0-9_./:=@+-]+$/u.test(value) ? value : shellQuote(value);
752
+ }
753
+
612
754
  async function handleRuntimeHealth({ options }: ParsedArgs): Promise<number> {
613
755
  const adapter = adapterOption(options);
614
756
  if (adapter !== 'extension') throw new Error('runtime-health currently applies to the extension adapter.');
@@ -769,6 +911,7 @@ function readJsonIfExists(file: string): unknown {
769
911
  }
770
912
 
771
913
  async function handleRuntimeDecision({ options }: ParsedArgs): Promise<number> {
914
+ applyRuntimeDirOption(options);
772
915
  const adapter = adapterOption(options);
773
916
  const target = targetPath(options);
774
917
  if (adapter === 'mobile') {
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.
@@ -159,21 +159,68 @@ Example:
159
159
  mm-harness doctor --fix --json
160
160
  mm-harness doctor --adapter mobile --target /path/to/checkout`,
161
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
+ },
162
195
  {
163
196
  name: 'install',
164
- 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.',
165
198
  example: 'mm-harness install',
166
199
  helpText: `mm-harness install [flags]
167
200
 
168
201
  Install the per-checkout runtime overlay (for CI / agents).
169
- 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.
170
205
 
171
206
  --adapter <mobile|extension|core> Target adapter (auto-detected inside a checkout)
172
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
173
219
 
174
220
  Example:
175
221
  mm-harness install
176
- 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`,
177
224
  },
178
225
  {
179
226
  name: 'verify',
@@ -386,8 +433,8 @@ const HELP_GROUPS: HelpGroup[] = [
386
433
  },
387
434
  {
388
435
  title: 'RUNTIME OVERLAY',
389
- blurb: 'install/verify/clean the per-checkout overlay (the everyday commands auto-ensure it)',
390
- commands: ['install', 'verify', 'cleanup'],
436
+ blurb: 'install/verify/clean the overlay, plus thin mobile slot provisioning',
437
+ commands: ['provision', 'install', 'verify', 'cleanup'],
391
438
  },
392
439
  {
393
440
  title: 'MAINTAIN',