@deeeed/metamask-harness 0.2.0 → 0.3.1

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 (110) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +47 -17
  3. package/{orchestration → adapters}/core/cleanup.sh +2 -2
  4. package/{orchestration → adapters}/core/inject.sh +8 -8
  5. package/{orchestration → adapters}/extension/cleanup.mjs +1 -1
  6. package/{orchestration → adapters}/extension/console-tail.mjs +3 -0
  7. package/{orchestration → adapters}/extension/ensure-browser.sh +4 -4
  8. package/{orchestration → adapters}/extension/inject.mjs +27 -31
  9. package/{orchestration → adapters}/extension/launch.sh +4 -4
  10. package/{orchestration → adapters}/extension/live.sh +5 -5
  11. package/{orchestration → adapters}/extension/readiness.mjs +12 -2
  12. package/{orchestration → adapters}/extension/refresh-build.sh +1 -1
  13. package/{orchestration → adapters}/extension/seed-fixture.sh +1 -1
  14. package/{orchestration → adapters}/extension/start-watch.sh +7 -7
  15. package/{runner → adapters}/extension/verify.sh +11 -11
  16. package/{orchestration → adapters}/extension/wallet-fixture-state.cjs +2 -2
  17. package/{orchestration → adapters}/manifest.json +96 -96
  18. package/{library/actions → adapters}/mobile/bridge-runtime/cdp-bridge.cjs +5 -5
  19. package/{orchestration → adapters}/mobile/cleanup.sh +3 -3
  20. package/{orchestration → adapters}/mobile/inject.sh +22 -24
  21. package/adapters/mobile/open-device.sh +260 -0
  22. package/{orchestration/compat-overlays → adapters/mobile/overlay/compat}/README.md +13 -4
  23. package/adapters/mobile/prewarm-bundle.sh +95 -0
  24. package/adapters/mobile/start-metro.sh +228 -0
  25. package/{runner → adapters}/mobile/verify.sh +6 -6
  26. package/adapters/mobile/wait-for-bridge.sh +108 -0
  27. package/adapters/mobile/yarn-setup.sh +20 -0
  28. package/{orchestration/lib → adapters/shared}/cli-ux.sh +5 -9
  29. package/{orchestration/lib → adapters/shared}/log-tui.mjs +57 -1
  30. package/{orchestration/lib → adapters/shared}/open-debug.mjs +32 -10
  31. package/{orchestration/lib → adapters/shared}/resolve-farmslot-ports.sh +47 -0
  32. package/adapters/shared/sync-wallet-fixture.sh +78 -0
  33. package/bin/mm-harness +37 -6
  34. package/docs/CLI-SPEC.md +41 -2
  35. package/docs/architecture.md +69 -38
  36. package/docs/runtime-file-conventions.md +3 -3
  37. package/library/actions/core/perps/_controller.mjs +1 -1
  38. package/library/actions/extension/platform/cdp.mjs +2 -2
  39. package/library/actions/extension/wallet/ensure_unlocked.mjs +1 -1
  40. package/library/actions/mobile/platform/bridge.mjs +1 -1
  41. package/library/actions/mobile/wallet/ensure_unlocked.mjs +1 -1
  42. package/library/actions/mobile/wallet/setup.mjs +2 -2
  43. package/library/library.json +1 -1
  44. package/package.json +8 -8
  45. package/{orchestration → src/adapters}/extension/runtime-decision.ts +5 -5
  46. package/{orchestration → src/adapters}/extension/runtime.ts +1 -1
  47. package/src/adapters/mobile/prepare.ts +210 -0
  48. package/{orchestration → src/adapters}/mobile/runtime-decision.ts +105 -5
  49. package/{runner/src → src}/adapters.ts +3 -3
  50. package/{orchestration/lib/cli-color.mjs → src/cli-color.ts} +21 -13
  51. package/{orchestration/lib/cli-commands.mjs → src/cli-commands.ts} +52 -45
  52. package/{orchestration/lib/cli-version.mjs → src/cli-version.ts} +35 -17
  53. package/{runner/src → src}/cli.ts +48 -40
  54. package/src/commands/debug.ts +63 -0
  55. package/src/commands/fixtures.ts +193 -0
  56. package/{runner/src → src}/commands/launch.ts +200 -54
  57. package/src/commands/logs.ts +83 -0
  58. package/{runner/src → src}/commands/shared.ts +40 -30
  59. package/src/commands/update.ts +316 -0
  60. package/{runner/src → src}/harness.ts +119 -16
  61. package/{runner/src → src}/heal-bounds.ts +20 -1
  62. package/src/index.ts +15 -0
  63. package/{runner/src → src}/live-adapter-contract.ts +1 -1
  64. package/{runner/src → src}/mm-harness-cli.ts +64 -3
  65. package/{runner/src → src}/paths.ts +2 -2
  66. package/{orchestration/lib/progress.mjs → src/progress.ts} +31 -21
  67. package/docs/extension-runtime-commands.md +0 -60
  68. package/docs/harness-cli.md +0 -43
  69. package/orchestration/compat-overlays/mobile/README.md +0 -13
  70. package/orchestration/doctor.mjs +0 -72
  71. package/orchestration/lib/cli-home.mjs +0 -354
  72. package/orchestration/lib/recipe-paths.mjs +0 -26
  73. package/orchestration/mobile/launch.sh +0 -137
  74. package/orchestration/mobile/live.sh +0 -125
  75. package/orchestration/porcelain/metamask-recipe +0 -99
  76. package/orchestration/porcelain/mm-recipe +0 -1591
  77. package/orchestration/porcelain/mme-recipe +0 -1181
  78. package/runner/src/commands/debug.ts +0 -44
  79. package/runner/src/commands/fixtures.ts +0 -99
  80. package/runner/src/commands/logs.ts +0 -60
  81. package/runner/src/index.ts +0 -6
  82. /package/{orchestration → adapters}/extension/launch-browser.cjs +0 -0
  83. /package/{orchestration → adapters}/extension/pin-remote-flags.cjs +0 -0
  84. /package/{orchestration → adapters}/extension/sidepanel-toggle.sh +0 -0
  85. /package/{orchestration → adapters}/extension/snapshot-dist.sh +0 -0
  86. /package/{library/actions → adapters}/mobile/bridge-runtime/lib/cdp-eval.cjs +0 -0
  87. /package/{library/actions → adapters}/mobile/bridge-runtime/lib/config.cjs +0 -0
  88. /package/{library/actions → adapters}/mobile/bridge-runtime/lib/issue-capture.cjs +0 -0
  89. /package/{library/actions → adapters}/mobile/bridge-runtime/lib/target-discovery.cjs +0 -0
  90. /package/{library/actions → adapters}/mobile/bridge-runtime/lib/ws-client.cjs +0 -0
  91. /package/{library/actions → adapters}/mobile/bridge-runtime/setup-wallet.sh +0 -0
  92. /package/{library/actions/mobile/app-overlay → adapters/mobile/overlay}/app/dev-tools/AgenticService/AgentStepHud.tsx.patch +0 -0
  93. /package/{library/actions/mobile/app-overlay → adapters/mobile/overlay}/app/dev-tools/AgenticService/AgenticService.ts.patch +0 -0
  94. /package/{orchestration/compat-overlays/mobile → adapters/mobile/overlay/compat}/rn81-message-event-source.patch +0 -0
  95. /package/{orchestration/lib → adapters/shared}/activate-repo-node.sh +0 -0
  96. /package/{orchestration/lib → adapters/shared}/ensure-runner-deps.sh +0 -0
  97. /package/{orchestration/lib → adapters/shared}/harness-path.sh +0 -0
  98. /package/{orchestration/lib → adapters/shared}/hash-helpers.sh +0 -0
  99. /package/{orchestration/lib → adapters/shared}/json-field.sh +0 -0
  100. /package/{orchestration/lib → adapters/shared}/path-defaults.json +0 -0
  101. /package/{orchestration → src/adapters}/extension/ensure-ready.ts +0 -0
  102. /package/{orchestration → src/adapters}/extension/extension-id.ts +0 -0
  103. /package/{orchestration → src/adapters}/mobile/deps-markers.ts +0 -0
  104. /package/{runner/src → src}/completions-cache.ts +0 -0
  105. /package/{runner/src → src}/doctor.ts +0 -0
  106. /package/{runner/src → src}/manifest.ts +0 -0
  107. /package/{runner/src → src}/recording-target.ts +0 -0
  108. /package/{runner/src → src}/run-recording.ts +0 -0
  109. /package/{runner/src → src}/runner.ts +0 -0
  110. /package/{runner/src → src}/types.ts +0 -0
@@ -0,0 +1,193 @@
1
+ // fixtures <sync|set> — manage wallet DATA (never overlay/runtime state). `sync`
2
+ // re-renders the wallet fixture files; `set` applies THE canonical fixture (SRP/
3
+ // password read FROM the fixture, never typed) and always teaches the edit path.
4
+
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+
8
+ import { runnerDir, walletFixturePath } from '../paths.ts';
9
+ import type { MetaMaskRecipeAdapter } from '../types.ts';
10
+ import { ADAPTER_DETECT_NEXT, EXIT, flag, parseFlags, resolveAdapter, spawnScript, str, targetOf, usageOut } from './shared.ts';
11
+
12
+ // One node injected from cli.ts so `fixtures set` can reuse the real engine path
13
+ // (`call`'s one-node machinery) without a circular import.
14
+ export interface CommandDeps {
15
+ runOneNode: (
16
+ adapter: MetaMaskRecipeAdapter,
17
+ action: string,
18
+ args: Record<string, string>,
19
+ target: string,
20
+ actionManifest: string | undefined,
21
+ ) => Promise<{ status: 'pass' | 'fail' }>;
22
+ }
23
+
24
+ const FIXTURES_BOOLEANS = new Set(['json']);
25
+
26
+ // Patterns indicating setup-wallet failed due to a Metro/bridge issue (not a bad
27
+ // fixture). A clean Metro restart + relaunch is likely to resolve these.
28
+ // Mirrors baseline mm-recipe:820-824 setup_wallet_failure_recoverable patterns.
29
+ const RECOVERABLE_SETUP_WALLET_PATTERNS = [
30
+ 'CDP not reachable',
31
+ 'CDP bridge',
32
+ 'is not installed',
33
+ 'Engine.context.KeyringController not available',
34
+ 'Engine does not exist',
35
+ "Cannot read property 'transactions'",
36
+ 'bridge-not-ready',
37
+ 'debug-target-missing',
38
+ 'CDP eval failed',
39
+ 'CDP eval-async failed',
40
+ ];
41
+
42
+ function isRecoverableSetupWalletFailure(output: string): boolean {
43
+ return RECOVERABLE_SETUP_WALLET_PATTERNS.some((p) => output.includes(p));
44
+ }
45
+
46
+ export async function handleFixtures(argv: string[], deps: CommandDeps): Promise<number> {
47
+ const { positional, options } = parseFlags(argv, FIXTURES_BOOLEANS);
48
+ const json = flag(options, 'json');
49
+ const sub = positional[0];
50
+ if (sub !== 'sync' && sub !== 'set') {
51
+ return usageOut(json, 'fixtures', 'fixtures requires a subcommand: mm-harness fixtures <sync|set>', 'mm-harness fixtures sync or mm-harness fixtures set');
52
+ }
53
+ const target = targetOf(options);
54
+ const adapter = resolveAdapter(options, target);
55
+ if (!adapter) {
56
+ return usageOut(json, 'fixtures', `could not detect the MetaMask repo type for ${target}`, ADAPTER_DETECT_NEXT);
57
+ }
58
+ if (adapter === 'core') return usageOut(json, 'fixtures', 'core is headless; it has no wallet fixture.', 'mm-harness run <recipe> # run recipes against the headless core');
59
+
60
+ const canonicalFixture = walletFixturePath(target);
61
+
62
+ if (sub === 'sync') {
63
+ const exitCode = fixturesSync(adapter, target, json);
64
+ if (json) {
65
+ console.log(
66
+ JSON.stringify(
67
+ { schemaVersion: 1, command: 'fixtures', action: 'sync', adapter, fixture: canonicalFixture, exitCode },
68
+ null,
69
+ 2,
70
+ ),
71
+ );
72
+ }
73
+ return exitCode;
74
+ }
75
+
76
+ // set — apply THE canonical wallet fixture (password read FROM the fixture).
77
+ // Env-gap resolution (docs/CLI-SPEC.md Part 4): flag > env RECIPE_WALLET_FIXTURE
78
+ // > the slot's canonical fixture.
79
+ const fixturePath = path.resolve(str(options, 'fixture') ?? process.env.RECIPE_WALLET_FIXTURE ?? canonicalFixture);
80
+ let status: 'pass' | 'fail';
81
+ if (adapter === 'mobile') {
82
+ // Compose setup-wallet.sh directly (requires bridge to be live).
83
+ const setupWalletSh = path.join(runnerDir, 'adapters/mobile/bridge-runtime/setup-wallet.sh');
84
+ const previousAppRoot = process.env.APP_ROOT;
85
+ process.env.APP_ROOT = target;
86
+ try {
87
+ let result = spawnScript(setupWalletSh, ['--fixture', fixturePath], target, json);
88
+ // One clean-Metro auto-retry on recoverable failures (CDP unreachable, bridge
89
+ // not installed). RECIPE_SETUP_WALLET_RETRIED guards against infinite loops.
90
+ if (result.status !== 0 && isRecoverableSetupWalletFailure(result.output) && !process.env['RECIPE_SETUP_WALLET_RETRIED']) {
91
+ process.env['RECIPE_SETUP_WALLET_RETRIED'] = '1';
92
+ if (!json) process.stderr.write(' setup-wallet: recoverable failure — restarting Metro and retrying\n Next: wait for relaunch, then wallet setup will retry automatically\n');
93
+ const { prepareMobile } = await import('../adapters/mobile/prepare.ts');
94
+ const platform = str(options, 'platform') ?? process.env['MOBILE_PLATFORM'] ?? 'ios';
95
+ const relaunchResult = await prepareMobile(target, { platform, json, preflightMode: 'auto', clearMetro: true });
96
+ if (relaunchResult.status === 0) {
97
+ result = spawnScript(setupWalletSh, ['--fixture', fixturePath], target, json);
98
+ }
99
+ }
100
+ status = result.status === 0 ? 'pass' : 'fail';
101
+ } finally {
102
+ delete process.env['RECIPE_SETUP_WALLET_RETRIED'];
103
+ if (previousAppRoot === undefined) delete process.env.APP_ROOT;
104
+ else process.env.APP_ROOT = previousAppRoot;
105
+ }
106
+ if (!json && status === 'fail') {
107
+ console.error(' Next: mm-harness launch ios # relaunch the dev client, then retry: mm-harness fixtures set');
108
+ }
109
+ } else {
110
+ // Extension has no standalone set arm — reuse call's one-node machinery via
111
+ // the real engine path (metamask.wallet.setup in the manifest).
112
+ if (!fs.existsSync(fixturePath)) {
113
+ return usageOut(
114
+ json,
115
+ 'fixtures',
116
+ `no wallet fixture at ${fixturePath}.`,
117
+ 'create it (or pass --fixture <path>), then re-run: mm-harness fixtures set',
118
+ );
119
+ }
120
+ const previousFixtureEnv = process.env.RECIPE_WALLET_FIXTURE;
121
+ process.env.RECIPE_WALLET_FIXTURE = fixturePath;
122
+ try {
123
+ const result = await deps.runOneNode('extension', 'metamask.wallet.setup', {}, target, str(options, 'actionManifest'));
124
+ status = result.status;
125
+ } finally {
126
+ if (previousFixtureEnv === undefined) delete process.env.RECIPE_WALLET_FIXTURE;
127
+ else process.env.RECIPE_WALLET_FIXTURE = previousFixtureEnv;
128
+ }
129
+ if (!json && status === 'fail') {
130
+ console.error(' Next: mm-harness launch --build # rebuild and relaunch, then retry: mm-harness fixtures set');
131
+ }
132
+ }
133
+
134
+ // Always teach where to customize the wallet.
135
+ const teaching = `Wallet fixture applied. Want different accounts? Edit: ${canonicalFixture}`;
136
+ if (json) {
137
+ console.log(
138
+ JSON.stringify(
139
+ {
140
+ schemaVersion: 1,
141
+ command: 'fixtures',
142
+ action: 'set',
143
+ adapter,
144
+ fixture: fixturePath,
145
+ canonicalFixture,
146
+ status,
147
+ exitCode: status === 'pass' ? EXIT.ok : EXIT.runtime,
148
+ message: teaching,
149
+ error: status === 'fail' ? {
150
+ code: 'SETUP_WALLET_FAILED',
151
+ message: 'wallet fixture setup failed',
152
+ userAction: adapter === 'mobile'
153
+ ? 'mm-harness launch ios # relaunch the dev client, then retry: mm-harness fixtures set'
154
+ : 'mm-harness launch --build # rebuild and relaunch, then retry: mm-harness fixtures set',
155
+ } : null,
156
+ },
157
+ null,
158
+ 2,
159
+ ),
160
+ );
161
+ } else {
162
+ console.error(teaching);
163
+ }
164
+ return status === 'pass' ? EXIT.ok : EXIT.runtime;
165
+ }
166
+
167
+ // Sync the overlay installation + wallet fixture for the checkout.
168
+ // Re-runs the adapter inject to refresh patched files, then copies the wallet
169
+ // fixture to the canonical runtime location via sync-wallet-fixture.sh.
170
+ function fixturesSync(adapter: 'mobile' | 'extension', target: string, json: boolean): number {
171
+ const mmHarnessBin = path.join(runnerDir, 'bin/mm-harness');
172
+ const installResult = spawnScript(
173
+ mmHarnessBin,
174
+ ['install', '--adapter', adapter, '--target', target],
175
+ target,
176
+ json,
177
+ );
178
+ if (installResult.status !== 0) {
179
+ if (!json) {
180
+ console.error(`✗ mm-harness fixtures sync: overlay reinstall failed.\n Next: mm-harness install --target ${target} # diagnose the install failure`);
181
+ }
182
+ return EXIT.runtime;
183
+ }
184
+
185
+ const syncFixtureSh = path.join(runnerDir, 'adapters/shared/sync-wallet-fixture.sh');
186
+ const syncArgs = ['--target', target];
187
+ if (process.env.CDP_PORT) syncArgs.push('--cdp-port', process.env.CDP_PORT);
188
+ if (process.env.RECIPE_SLOT_ID) syncArgs.push('--slot-id', process.env.RECIPE_SLOT_ID);
189
+ spawnScript(syncFixtureSh, syncArgs, target, json);
190
+ // Non-zero from sync-wallet-fixture.sh is informational (no fixture source);
191
+ // the install succeeded so the overlay is fresh.
192
+ return EXIT.ok;
193
+ }
@@ -1,26 +1,29 @@
1
1
  // launch — start the app (Metro/build + surface), auto-ensure the runtime overlay,
2
2
  // and heal transport within bounds. Owns launch grammar, build-tier selection,
3
- // quick-launch change detection, and the --json contract. Device orchestration is
4
- // not rewritten: each tier composes the same porcelain the standalone bins expose,
5
- // and --verify reuses the overlay `live` sequence via handleHarness.
3
+ // quick-launch change detection, and the --json contract. Device orchestration
4
+ // composes adapters/ scripts directly; --verify reuses the overlay `live`
5
+ // sequence via handleHarness.
6
6
 
7
7
  import { execFileSync } from 'node:child_process';
8
8
  import fs from 'node:fs';
9
+ import path from 'node:path';
9
10
 
10
11
  import { handleHarness } from '../harness.ts';
11
- import { recipeRuntimePath } from '../paths.ts';
12
+ import { recipeHarnessPath, recipeRuntimeDir, recipeRuntimePath, runnerDir } from '../paths.ts';
12
13
  import type { MetaMaskRecipeAdapter } from '../types.ts';
14
+ import { prepareMobile } from '../adapters/mobile/prepare.ts';
13
15
  import {
16
+ ADAPTER_DETECT_NEXT,
14
17
  EXIT,
15
18
  flag,
16
19
  parseFlags,
17
20
  resolveAdapter,
18
- spawnPorcelain,
21
+ spawnScript,
19
22
  str,
20
23
  targetOf,
21
24
  usageOut,
22
25
  } from './shared.ts';
23
- import type { DeviceAdapter } from './shared.ts';
26
+ import type { DeviceAdapter, ScriptResult } from './shared.ts';
24
27
  import {
25
28
  RECOVERY_CODE,
26
29
  checkHealBounds,
@@ -36,7 +39,7 @@ interface LaunchFailure {
36
39
  recoverable: boolean;
37
40
  userAction?: string;
38
41
  exitCode: number;
39
- // The original porcelain output, surfaced verbatim (never replaced by the note).
42
+ // The original script output, surfaced verbatim (never replaced by the note).
40
43
  originalError?: string;
41
44
  }
42
45
 
@@ -58,7 +61,7 @@ export async function handleLaunch(argv: string[]): Promise<number> {
58
61
  const target = targetOf(options);
59
62
 
60
63
  const heal = parseHeal(options, 'auto');
61
- if (typeof heal !== 'string') return usageOut(json, 'launch', heal.error);
64
+ if (typeof heal !== 'string') return usageOut(json, 'launch', heal.error, 'use --heal off|infra-only|auto');
62
65
 
63
66
  // Grammar: mobile requires an ios|android positional (also disambiguates the
64
67
  // adapter); extension takes no positional; core is headless (teaching error).
@@ -72,20 +75,17 @@ export async function handleLaunch(argv: string[]): Promise<number> {
72
75
  const adapter = resolveAdapter(options, target, adapterHint);
73
76
 
74
77
  if (!adapter) {
75
- return usageOut(
76
- json,
77
- 'launch',
78
- `could not detect the MetaMask repo type for ${target}\n Next: pass --adapter mobile|extension, or run inside a checkout.`,
79
- );
78
+ return usageOut(json, 'launch', `could not detect the MetaMask repo type for ${target}`, ADAPTER_DETECT_NEXT);
80
79
  }
81
80
  if (adapter === 'core') {
82
- return usageOut(json, 'launch', 'core is headless; there is nothing to launch. Use: mm-harness verify');
81
+ return usageOut(json, 'launch', 'core is headless; there is nothing to launch.', 'mm-harness verify');
83
82
  }
84
83
  if (adapter === 'mobile' && !mobileTarget) {
85
84
  return usageOut(
86
85
  json,
87
86
  'launch',
88
- 'target is required for mobile.\n Run: mm-harness launch ios or mm-harness launch android',
87
+ 'target is required for mobile.',
88
+ 'mm-harness launch ios or mm-harness launch android',
89
89
  );
90
90
  }
91
91
 
@@ -96,7 +96,7 @@ export async function handleLaunch(argv: string[]): Promise<number> {
96
96
 
97
97
  // --runway is mobile-only.
98
98
  if (wantRunway && adapter !== 'mobile') {
99
- return usageOut(json, 'launch', 'runway is mobile-only. Drop --runway for the extension.');
99
+ return usageOut(json, 'launch', 'runway is mobile-only.', 'drop --runway for the extension');
100
100
  }
101
101
  // --fullscreen is the explicit override when both display flags are passed.
102
102
  const displayMode =
@@ -109,10 +109,15 @@ export async function handleLaunch(argv: string[]): Promise<number> {
109
109
  for (const portFlag of ['cdpPort', 'watcherPort'] as const) {
110
110
  const value = str(options, portFlag);
111
111
  if (value !== undefined && !/^\d+$/u.test(value)) {
112
- return usageOut(json, 'launch', `--${portFlag === 'cdpPort' ? 'cdp-port' : 'watcher-port'} must be numeric (got: ${value}).`);
112
+ return usageOut(
113
+ json,
114
+ 'launch',
115
+ `--${portFlag === 'cdpPort' ? 'cdp-port' : 'watcher-port'} must be numeric (got: ${value}).`,
116
+ `pass a numeric port, e.g. --${portFlag === 'cdpPort' ? 'cdp-port 6663' : 'watcher-port 8081'}`,
117
+ );
113
118
  }
114
119
  }
115
- applyLaunchEnvOverrides(options, adapter, mobileTarget);
120
+ applyLaunchEnvOverrides(options, adapter, mobileTarget, target);
116
121
 
117
122
  const tier = wantVerify ? 'verify' : wantBuild ? 'build' : 'quick';
118
123
  const state = newHealState();
@@ -123,9 +128,8 @@ export async function handleLaunch(argv: string[]): Promise<number> {
123
128
  return usageOut(
124
129
  json,
125
130
  'launch',
126
- `native build inputs changed since last build — run: mm-harness launch ${
127
- adapter === 'mobile' ? `${mobileTarget} ` : ''
128
- }--build`,
131
+ 'native build inputs changed since last build.',
132
+ `mm-harness launch ${adapter === 'mobile' ? `${mobileTarget} ` : ''}--build`,
129
133
  );
130
134
  }
131
135
 
@@ -152,9 +156,8 @@ export async function handleLaunch(argv: string[]): Promise<number> {
152
156
  return code;
153
157
  }
154
158
 
155
- // Compose the launch tier via the porcelain (same path the porcelain commands use).
156
- const launchArgs = launchComposition(adapter, mobileTarget, tier, wantWatch);
157
- let attempt = spawnPorcelain(adapter, launchArgs, target, json);
159
+ // Compose the launch tier: mobile runs in-process; extension spawns adapters/ scripts.
160
+ let attempt = await executeComposition(adapter, mobileTarget, tier, wantWatch, target, json);
158
161
 
159
162
  if (attempt.status === 0) {
160
163
  return finishLaunch(json, adapter, mobileTarget, tier, displayMode, target, state);
@@ -182,12 +185,12 @@ export async function handleLaunch(argv: string[]): Promise<number> {
182
185
  });
183
186
  }
184
187
 
185
- // Infra recovery: attempt the SAME launch exactly once (the porcelain restarts
186
- // Metro / reopens Chrome). Push recoveryCode BEFORE the attempt so any
187
- // subsequent checkHealBounds call sees it and refuses a second loop.
188
+ // Infra recovery: attempt the SAME launch exactly once (Metro restarts /
189
+ // Chrome reopens). Push recoveryCode BEFORE the attempt so any subsequent
190
+ // checkHealBounds call sees it and refuses a second loop.
188
191
  const recoveryCode = RECOVERY_CODE[adapter];
189
192
  state.attemptedRecoveries.push(recoveryCode);
190
- attempt = spawnPorcelain(adapter, launchArgs, target, json);
193
+ attempt = await executeComposition(adapter, mobileTarget, tier, wantWatch, target, json);
191
194
  if (attempt.status === 0) {
192
195
  state.recovered.push(recoveryCode);
193
196
  return finishLaunch(json, adapter, mobileTarget, tier, displayMode, target, state);
@@ -202,15 +205,70 @@ export async function handleLaunch(argv: string[]): Promise<number> {
202
205
  });
203
206
  }
204
207
 
205
- // Map the env-gap flags onto the env vars the porcelain reads. Only the canonical
206
- // var plus its documented aliases are set, and only when the flag is present, so
207
- // flag > env > config/default holds. --device targets the simulator (ios) or the
208
- // adb serial (android); ports are set with their runtime aliases.
208
+ // Apply KEY=VALUE lines from slot resolution to process.env.
209
+ // overwrite=true pool match, always overrides existing env.
210
+ // overwrite=false formula match, only fills vars that are unset.
211
+ function applyKVLines(output: string, overwrite: boolean): void {
212
+ for (const line of output.split('\n')) {
213
+ const m = /^([A-Z_]+)=(.+)$/u.exec(line.trim());
214
+ if (!m) continue;
215
+ const [, key, val] = m;
216
+ switch (key) {
217
+ case 'WATCHER_PORT':
218
+ if (overwrite || !process.env['WATCHER_PORT']) {
219
+ process.env['WATCHER_PORT'] = val;
220
+ process.env['METRO_PORT'] = val;
221
+ process.env['RECIPE_WATCHER_PORT'] = val;
222
+ }
223
+ break;
224
+ case 'IOS_SIMULATOR':
225
+ if (overwrite || !process.env['IOS_SIMULATOR']) process.env['IOS_SIMULATOR'] = val;
226
+ break;
227
+ case 'SLOT_ID':
228
+ if (overwrite || !process.env['RECIPE_SLOT_ID']) process.env['RECIPE_SLOT_ID'] = val;
229
+ break;
230
+ }
231
+ }
232
+ }
233
+
234
+ // Resolve mobile slot port/simulator from the farmslot pool (pool-wins-over-env)
235
+ // or from the slot-suffix formula (only fills unset vars). Called before explicit
236
+ // CLI flag overrides so flags always win at the top.
237
+ function resolveMobileSlotPorts(target: string): void {
238
+ const resolveScript = path.join(runnerDir, 'adapters/shared/resolve-farmslot-ports.sh');
239
+ try {
240
+ // Pool match always wins — overwrite whatever is in the environment.
241
+ const poolOut = execFileSync('bash', [
242
+ '-c', `source "${resolveScript}" && resolve_farmslot_ports_by_repo "${target}"`,
243
+ ], { encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] });
244
+ if (poolOut.trim()) {
245
+ applyKVLines(poolOut, true);
246
+ return;
247
+ }
248
+ } catch { /* no pool match — fall through to formula */ }
249
+ try {
250
+ // Formula match only fills unset vars (never overrides explicit env/pool).
251
+ const defOut = execFileSync('bash', [
252
+ '-c', `source "${resolveScript}" && resolve_mobile_slot_defaults "${target}"`,
253
+ ], { encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] });
254
+ if (defOut.trim()) applyKVLines(defOut, false);
255
+ } catch { /* no slot suffix in dir name — stays at env defaults */ }
256
+ }
257
+
258
+ // Map the env-gap flags onto the env vars the leaf scripts read. For mobile, slot
259
+ // port/simulator defaults are resolved first (pool-wins, then formula-fills-empty)
260
+ // so that the harness respects slot isolation; explicit CLI flags applied below
261
+ // win over everything. Extension: flag > env > config/default.
209
262
  function applyLaunchEnvOverrides(
210
263
  options: Record<string, string | boolean>,
211
264
  adapter: DeviceAdapter,
212
265
  mobileTarget: string | undefined,
266
+ target: string,
213
267
  ): void {
268
+ // Mobile slot isolation: resolve WATCHER_PORT / IOS_SIMULATOR / SLOT_ID from
269
+ // the farmslot pool or directory-suffix formula BEFORE applying explicit flags.
270
+ if (adapter === 'mobile') resolveMobileSlotPorts(target);
271
+
214
272
  const device = str(options, 'device');
215
273
  if (device && adapter === 'mobile') {
216
274
  if (mobileTarget === 'android') {
@@ -261,35 +319,119 @@ function nativeInputsChanged(target: string, adapter: MetaMaskRecipeAdapter): bo
261
319
  }
262
320
  }
263
321
 
264
- // Map (adapter, tier) the grounded porcelain sub-command.
265
- // mobile quick : mm-recipe <ios|android> (quick relaunch)
266
- // mobile build : mm-recipe up (decision-aware full build)
267
- // extension quick: mme-recipe rebuild (snapshot dist + reopen)
268
- // extension build: mme-recipe build (webpack build)
269
- // extension watch: mme-recipe watch (webpack watch)
270
- function launchComposition(
322
+ // Execute the launch composition for (adapter, tier). Mobile runs prepareMobile()
323
+ // in-process (no shell orchestrator); extension arms spawn adapters/ scripts directly.
324
+ //
325
+ // mobile quick|build prepareMobile() in-process leaf scripts
326
+ // extension rebuild → kill watcher + clear rebuild.log + live.sh --start-watch
327
+ // extension build → adapters/extension/start-watch.sh
328
+ // extension watch → adapters/extension/start-watch.sh
329
+ async function executeComposition(
271
330
  adapter: DeviceAdapter,
272
331
  mobileTarget: string | undefined,
273
332
  tier: 'quick' | 'build' | 'verify',
274
333
  wantWatch: boolean,
275
- ): string[] {
334
+ target: string,
335
+ json: boolean,
336
+ ): Promise<ScriptResult> {
276
337
  if (adapter === 'mobile') {
277
- if (tier === 'build') return ['up'];
278
- return [mobileTarget ?? 'ios'];
338
+ const platform = mobileTarget ?? 'ios';
339
+ const watcherPort = process.env.WATCHER_PORT ? parseInt(process.env.WATCHER_PORT, 10) : undefined;
340
+ const preflightMode = tier === 'build' ? 'auto' : 'fast';
341
+ return prepareMobile(target, { platform, json, watcherPort, preflightMode });
342
+ }
343
+
344
+ // Extension rebuild (quick relaunch): kill watcher → clear rebuild.log → live.sh --start-watch.
345
+ if (!wantWatch && tier === 'quick') {
346
+ return extensionRebuild(target, json);
347
+ }
348
+
349
+ // Extension build or watch: full webpack compile via start-watch.sh.
350
+ const startWatchSh = path.join(runnerDir, 'adapters/extension/start-watch.sh');
351
+ const watchArgs = ['--target', target];
352
+ if (process.env.WATCHER_PORT) watchArgs.push('--watcher-port', process.env.WATCHER_PORT);
353
+ return spawnScript(startWatchSh, watchArgs, target, json);
354
+ }
355
+
356
+ // Extension rebuild: kill the harness-owned webpack watcher (pid file + ps-scan
357
+ // for orphans), clear the rebuild log, then drive the installed overlay's
358
+ // live.sh --start-watch and tee its output into the rebuild log.
359
+ function extensionRebuild(target: string, json: boolean): ScriptResult {
360
+ const runtimeDirRel = recipeRuntimeDir();
361
+ const runtimeAbs = path.join(target, runtimeDirRel);
362
+ const webpackPidFile = path.join(runtimeAbs, 'recipe-harness-webpack.pid');
363
+ const rebuildLog = path.join(runtimeAbs, 'rebuild.log');
364
+
365
+ // E1a: Kill harness-owned watcher via pid file, then ps-scan for orphans.
366
+ try {
367
+ const pid = fs.readFileSync(webpackPidFile, 'utf8').trim();
368
+ if (/^\d+$/u.test(pid)) {
369
+ try { process.kill(Number(pid), 'SIGTERM'); } catch { /* already dead */ }
370
+ }
371
+ fs.rmSync(webpackPidFile, { force: true });
372
+ } catch { /* no pid file */ }
373
+ // Scan for any remaining orphan webpack/yarn-start processes in this checkout.
374
+ try {
375
+ const psOut = execFileSync('ps', ['-axo', 'pid=,command='], { encoding: 'utf8' });
376
+ const orphanPids: number[] = [];
377
+ for (const line of psOut.split('\n')) {
378
+ const match = /^\s*(\d+)\s+(.*)$/u.exec(line);
379
+ if (!match) continue;
380
+ const [, pidStr, cmd] = match;
381
+ const isWatcher =
382
+ cmd.includes('yarn start') ||
383
+ cmd.includes('webpack --watch') ||
384
+ cmd.includes('development/webpack/launch.ts --watch');
385
+ if (!isWatcher) continue;
386
+ if (cmd.includes(target)) {
387
+ orphanPids.push(Number(pidStr));
388
+ continue;
389
+ }
390
+ // lsof cwd fallback for processes that don't embed the path in argv.
391
+ try {
392
+ const cwd = execFileSync('lsof', ['-a', `-p${pidStr}`, '-dcwd', '-Fn'], {
393
+ encoding: 'utf8',
394
+ timeout: 2000,
395
+ });
396
+ if (cwd.split('\n').some((l) => l.startsWith('n') && l.slice(1) === target)) {
397
+ orphanPids.push(Number(pidStr));
398
+ }
399
+ } catch { /* lsof unavailable or permission denied */ }
400
+ }
401
+ if (orphanPids.length > 0) {
402
+ for (const pid of orphanPids) {
403
+ try { process.kill(pid, 'SIGTERM'); } catch { /* already dead */ }
404
+ }
405
+ // Brief pause then force-kill survivors.
406
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 2000);
407
+ for (const pid of orphanPids) {
408
+ try { process.kill(pid, 'SIGKILL'); } catch { /* already dead */ }
409
+ }
410
+ }
411
+ } catch { /* ps not available */ }
412
+
413
+ // E1c: Clear the rebuild log (directory must exist for tee).
414
+ fs.mkdirSync(path.dirname(rebuildLog), { recursive: true });
415
+ fs.writeFileSync(rebuildLog, '');
416
+
417
+ const liveScript = recipeHarnessPath(target, 'extension', 'scripts', 'live.sh');
418
+ const liveArgs = ['--target', target, '--start-watch'];
419
+ if (process.env.CDP_PORT) liveArgs.push('--cdp-port', process.env.CDP_PORT);
420
+ if (process.env.WATCHER_PORT) liveArgs.push('--watcher-port', process.env.WATCHER_PORT);
421
+ const result = spawnScript(liveScript, liveArgs, target, json);
422
+ // Tee live.sh output into rebuild.log for post-mortem diagnosis.
423
+ if (result.output) {
424
+ try { fs.appendFileSync(rebuildLog, result.output); } catch { /* best-effort */ }
279
425
  }
280
- // extension
281
- if (wantWatch) return ['watch'];
282
- if (tier === 'build') return ['build'];
283
- return ['rebuild'];
426
+ return result;
284
427
  }
285
428
 
286
- // Apply the extension display mode AFTER the base launch succeeded, then emit the
287
- // pass summary. Fullscreen = the plain rebuild/build/watch launch above (the
288
- // default popup/home window). Sidepanel additionally drives the chrome.sidePanel
289
- // mechanism via the porcelain: `mme-recipe sidepanel open`
290
- // (orchestration/porcelain/mme-recipe orchestration/extension/sidepanel-toggle.sh `open`), so
291
- // --sidepanel and --fullscreen compose DIFFERENT porcelain calls and the display
292
- // mode actually reaches orchestration. Mobile has no side panel, so the flag is a
429
+ // Apply the extension display mode AFTER the base launch succeeded, then emit
430
+ // the pass summary. Fullscreen = the plain rebuild/build/watch launch above
431
+ // (default popup/home window). Sidepanel additionally drives the
432
+ // chrome.sidePanel mechanism via adapters/extension/sidepanel-toggle.sh.
433
+ // --sidepanel and --fullscreen compose DIFFERENT script calls and the display
434
+ // mode actually reaches the browser. Mobile has no side panel; the flag is a
293
435
  // no-op there.
294
436
  function finishLaunch(
295
437
  json: boolean,
@@ -301,12 +443,16 @@ function finishLaunch(
301
443
  state: HealState,
302
444
  ): number {
303
445
  if (adapter === 'extension' && displayMode === 'sidepanel') {
304
- const sidepanel = spawnPorcelain('extension', ['sidepanel', 'open'], target, json);
446
+ const sidepanelSh = path.join(runnerDir, 'adapters/extension/sidepanel-toggle.sh');
447
+ const sidepanelArgs = ['open'];
448
+ if (process.env.CDP_PORT) sidepanelArgs.push('--cdp-port', process.env.CDP_PORT);
449
+ const sidepanel = spawnScript(sidepanelSh, sidepanelArgs, target, json);
305
450
  if (sidepanel.status !== 0) {
306
451
  return launchFail(json, adapter, mobileTarget, tier, state, {
307
452
  code: 'SIDEPANEL_OPEN_FAILED',
308
453
  message: 'app launched but opening the side panel failed.',
309
454
  recoverable: false,
455
+ userAction: `bash adapters/extension/sidepanel-toggle.sh open --cdp-port ${process.env.CDP_PORT ?? '<CDP_PORT>'}`,
310
456
  exitCode: EXIT.runtime,
311
457
  originalError: sidepanel.output.trim() || undefined,
312
458
  });
@@ -0,0 +1,83 @@
1
+ // logs — tail Metro/webpack + app logs by composing log-tui.mjs (compact) or
2
+ // plain `tail -f` (--full). Teaches (exit 2) when nothing is running for the
3
+ // checkout.
4
+
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+
8
+ import { recipeRuntimePath, runnerDir } from '../paths.ts';
9
+ import { ADAPTER_DETECT_NEXT, EXIT, flag, parseFlags, resolveAdapter, spawnScript, str, targetOf, usageOut } from './shared.ts';
10
+
11
+ const LOGS_BOOLEANS = new Set(['full', 'json']);
12
+
13
+ export async function handleLogs(argv: string[]): Promise<number> {
14
+ const { options } = parseFlags(argv, LOGS_BOOLEANS);
15
+ const json = flag(options, 'json');
16
+ const target = targetOf(options);
17
+ const adapter = resolveAdapter(options, target);
18
+
19
+ if (!adapter) {
20
+ return usageOut(json, 'logs', `could not detect the MetaMask repo type for ${target}`, ADAPTER_DETECT_NEXT);
21
+ }
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');
24
+ }
25
+
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');
29
+ }
30
+
31
+ // Env-gap flag (docs/CLI-SPEC.md Part 4): --events sets the compact event count
32
+ // read from RECIPE_LOG_EVENTS. Resolution is flag > env > default: the flag wins
33
+ // by setting the env; an absent flag leaves the agent/CI env alone.
34
+ const events = str(options, 'events');
35
+ if (events !== undefined) {
36
+ if (!/^\d+$/u.test(events)) {
37
+ return usageOut(json, 'logs', `--events must be numeric (got: ${events}).`, 'mm-harness logs --events 20');
38
+ }
39
+ process.env.RECIPE_LOG_EVENTS = events;
40
+ }
41
+
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';
47
+ return usageOut(
48
+ json,
49
+ 'logs',
50
+ `nothing running for this checkout (no ${path.basename(logFile)}).`,
51
+ launchHint,
52
+ );
53
+ }
54
+
55
+ const full = flag(options, 'full');
56
+ if (full) {
57
+ // Raw follow: the human explicitly wants unfiltered output.
58
+ const result = spawnScript('tail', ['-n', '+1', '-F', logFile], target, json);
59
+ return result.status === 0 ? EXIT.ok : EXIT.runtime;
60
+ }
61
+
62
+ // Compact mode: last N events + pointer to full log.
63
+ const logTui = path.join(runnerDir, 'adapters/shared/log-tui.mjs');
64
+ const eventCount = process.env.RECIPE_LOG_EVENTS ?? '20';
65
+ const uiMode = process.env.RECIPE_LOG_UI_MODE ?? 'compact';
66
+ const result = spawnScript(
67
+ process.execPath,
68
+ [logTui, 'tail', '--log', logFile, '--events', eventCount, '--follow', '--mode', uiMode],
69
+ target,
70
+ json,
71
+ );
72
+
73
+ if (json) {
74
+ console.log(
75
+ JSON.stringify(
76
+ { schemaVersion: 1, command: 'logs', adapter, source, logFile, exitCode: result.status === 0 ? EXIT.ok : EXIT.runtime },
77
+ null,
78
+ 2,
79
+ ),
80
+ );
81
+ }
82
+ return result.status === 0 ? EXIT.ok : EXIT.runtime;
83
+ }