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