@deeeed/metamask-harness 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/CHANGELOG.md +37 -20
  2. package/adapters/core/inject.sh +1 -0
  3. package/adapters/extension/cleanup.mjs +1 -1
  4. package/adapters/extension/inject.mjs +2 -1
  5. package/adapters/extension/launch-browser.cjs +18 -4
  6. package/adapters/extension/live.sh +14 -2
  7. package/adapters/extension/sidepanel-toggle.sh +33 -12
  8. package/adapters/extension/wallet-fixture-state.cjs +38 -24
  9. package/adapters/manifest.json +13 -0
  10. package/adapters/mobile/inject.sh +1 -0
  11. package/adapters/mobile/stop-metro.sh +20 -0
  12. package/adapters/shared/cli-ux.sh +20 -2
  13. package/adapters/shared/ensure-runner-deps.sh +30 -0
  14. package/adapters/shared/reap-checkout-metros.sh +53 -0
  15. package/adapters/shared/recipe-harness-root.mjs +23 -0
  16. package/adapters/shared/resolve-farmslot-ports-core.mjs +15 -6
  17. package/bin/mm-harness +45 -4
  18. package/dist/adapters/extension/runtime.js +3 -1
  19. package/dist/adapters/mobile/provision.js +34 -1
  20. package/dist/adapters/slot-ports.js +3 -8
  21. package/dist/cli.js +32 -1463
  22. package/dist/commands/call.js +183 -0
  23. package/dist/commands/completion-candidates.js +58 -0
  24. package/dist/commands/doctor.js +101 -0
  25. package/dist/commands/ensure-ready.js +24 -0
  26. package/dist/commands/flows.js +62 -0
  27. package/dist/commands/launch/extension.js +40 -0
  28. package/dist/commands/{launch.js → launch/index.js} +15 -47
  29. package/dist/commands/launch/mobile.js +10 -0
  30. package/dist/commands/manifest.js +72 -0
  31. package/dist/commands/parse-args.js +189 -0
  32. package/dist/commands/provision.js +136 -0
  33. package/dist/commands/resolve-extension.js +23 -0
  34. package/dist/commands/run-engine.js +341 -0
  35. package/dist/commands/run.js +217 -0
  36. package/dist/commands/runtime-decision.js +58 -0
  37. package/dist/commands/runtime-health.js +25 -0
  38. package/dist/commands/runtime-launch.js +139 -0
  39. package/dist/commands/self-test.js +52 -0
  40. package/dist/commands/stop.js +52 -0
  41. package/dist/harness.js +8 -48
  42. package/dist/mm-harness-cli.js +13 -8
  43. package/docs/CLI-SPEC.md +1 -1
  44. package/docs/CODE-MAP.md +62 -0
  45. package/library/README.md +14 -0
  46. package/library/actions/extension/platform/cdp.mjs +1 -1
  47. package/library/actions/extension/wallet/ensure_unlocked.mjs +6 -0
  48. package/library/actions/mobile/wallet/ensure_unlocked.mjs +13 -1
  49. package/package.json +6 -7
  50. package/src/adapters/core/surface.ts +0 -71
  51. package/src/adapters/extension/ensure-ready.ts +0 -185
  52. package/src/adapters/extension/extension-id.ts +0 -107
  53. package/src/adapters/extension/runtime-decision.ts +0 -445
  54. package/src/adapters/extension/runtime.ts +0 -407
  55. package/src/adapters/extension/surface.ts +0 -88
  56. package/src/adapters/mobile/deps-markers.ts +0 -21
  57. package/src/adapters/mobile/prepare.ts +0 -246
  58. package/src/adapters/mobile/provision.ts +0 -594
  59. package/src/adapters/mobile/runtime-decision.ts +0 -466
  60. package/src/adapters/mobile/surface.ts +0 -71
  61. package/src/adapters/resolve-farmslot-ports.ts +0 -13
  62. package/src/adapters/slot-ports.ts +0 -158
  63. package/src/adapters/surface.ts +0 -117
  64. package/src/adapters.ts +0 -601
  65. package/src/cli-color.ts +0 -92
  66. package/src/cli-commands.ts +0 -250
  67. package/src/cli-version.ts +0 -141
  68. package/src/cli.ts +0 -2091
  69. package/src/commands/debug.ts +0 -65
  70. package/src/commands/fixtures.ts +0 -198
  71. package/src/commands/launch.ts +0 -470
  72. package/src/commands/logs.ts +0 -99
  73. package/src/commands/shared.ts +0 -235
  74. package/src/commands/update.ts +0 -316
  75. package/src/completions-cache.ts +0 -86
  76. package/src/doctor.ts +0 -215
  77. package/src/harness.ts +0 -797
  78. package/src/heal-bounds.ts +0 -198
  79. package/src/index.ts +0 -15
  80. package/src/leaf-invoke.ts +0 -28
  81. package/src/live-adapter-contract.ts +0 -274
  82. package/src/manifest.ts +0 -47
  83. package/src/mm-harness-cli.ts +0 -655
  84. package/src/paths.ts +0 -198
  85. package/src/progress.ts +0 -117
  86. package/src/recording-target.ts +0 -147
  87. package/src/run-recording.ts +0 -329
  88. package/src/runner.ts +0 -108
  89. package/src/types.ts +0 -57
package/src/adapters.ts DELETED
@@ -1,601 +0,0 @@
1
- import http from 'node:http';
2
- import path from 'node:path';
3
- import { compatibilityMode, fixtureSummary, repoShape } from './doctor.ts';
4
- import { runLiveAdapterScript } from './live-adapter-contract.ts';
5
- import { withExtensionPage } from '../library/actions/extension/platform/cdp.mjs';
6
- import { bridgeCommand, evalSync, simulatorScreenshot } from '../library/actions/mobile/platform/bridge.mjs';
7
- import type {
8
- ActionAdapter,
9
- ActionExecutionContext,
10
- ActionResult,
11
- UiActionTransport,
12
- } from '@farmslot/recipe-harness';
13
- import type { MetaMaskRecipeAdapter } from './types.ts';
14
-
15
- type ActionNode = Record<string, unknown>;
16
-
17
- function sleep(ms: number): Promise<void> {
18
- return new Promise((resolve) => setTimeout(resolve, ms));
19
- }
20
-
21
- interface MetaMaskUiActionInput {
22
- action: string;
23
- nodeId?: string;
24
- node: ActionNode;
25
- }
26
-
27
- type ActionExecutor = (
28
- node: ActionNode,
29
- context: ActionExecutionContext,
30
- ) => Promise<ActionResult> | ActionResult;
31
-
32
- function simpleAdapter(action: string, executor: ActionExecutor): ActionAdapter {
33
- return {
34
- action,
35
- async execute(node, context) {
36
- return executor(node, context);
37
- },
38
- };
39
- }
40
-
41
- const LIVE_ONLY_PERPS_ACTIONS = new Set([
42
- 'metamask.perps.read_positions',
43
- 'metamask.perps.ensure_positions',
44
- 'metamask.perps.assert_positions',
45
- 'metamask.perps.place_order',
46
- 'metamask.perps.close_positions',
47
- 'metamask.perps.read_orders',
48
- 'metamask.perps.close_orders',
49
- 'metamask.perps.ensure_orders',
50
- 'metamask.perps.assert_orders',
51
- 'metamask.perps.start_state',
52
- 'metamask.perps.teardown_state',
53
- ]);
54
-
55
- // read_account is implemented only by the headless `core` adapter (it reads
56
- // HyperLiquid account state directly via the standalone controller path). Mobile/
57
- // extension have no live adapter for it, so it is gated to core: not advertised by
58
- // the mobile/extension semantic adapter list, and only treated as a required live
59
- // action when running under core.
60
- const CORE_ONLY_PERPS_ACTIONS = new Set(['metamask.perps.read_account']);
61
- const LIVE_ONLY_WALLET_ACTIONS = new Set([
62
- 'metamask.wallet.setup',
63
- 'metamask.wallet.ensure_unlocked',
64
- 'metamask.wallet.select_account',
65
- 'metamask.wallet.read_state',
66
- ]);
67
- const LIVE_ONLY_APP_ACTIONS = new Set(['ui.navigate']);
68
-
69
- function requiresLiveAdapter(platform: MetaMaskRecipeAdapter, action: string) {
70
- return (
71
- LIVE_ONLY_PERPS_ACTIONS.has(action) ||
72
- (platform === 'core' && CORE_ONLY_PERPS_ACTIONS.has(action)) ||
73
- LIVE_ONLY_WALLET_ACTIONS.has(action) ||
74
- LIVE_ONLY_APP_ACTIONS.has(action)
75
- );
76
- }
77
-
78
- function liveRuntimeConfigured(platform: MetaMaskRecipeAdapter, node: ActionNode) {
79
- // core drives the perps controller directly (HTTP reads) — no CDP/bridge runtime to probe.
80
- if (platform === 'mobile' || platform === 'core') return true;
81
- return Boolean(node.cdp_port ?? process.env.CDP_PORT ?? process.env.RECIPE_CDP_PORT);
82
- }
83
-
84
- async function runLiveFirst(
85
- platform: MetaMaskRecipeAdapter,
86
- action: string,
87
- node: ActionNode,
88
- context: ActionExecutionContext,
89
- ): Promise<ActionResult | null> {
90
- if (node.allow_static_placeholder === true) {
91
- throw new Error(
92
- `${action} refused allow_static_placeholder. MetaMask runner proof runs must use live adapters and real artifacts.`,
93
- );
94
- }
95
- if (!requiresLiveAdapter(platform, action) && !liveRuntimeConfigured(platform, node)) return null;
96
- const live = await runLiveAdapterScript({ platform, action, node, context });
97
- if (!live) return null;
98
- const liveResult = isRecord(live.result) ? live.result : { result: live.result };
99
- const output = { ...liveResult, liveAdapter: live.script };
100
- return {
101
- output,
102
- artifacts:
103
- isRecord(live.result) && Array.isArray(live.result.artifacts)
104
- ? live.result.artifacts
105
- : undefined,
106
- };
107
- }
108
-
109
- function liveAdapterPathHint(platform: MetaMaskRecipeAdapter, action: string) {
110
- if (action.startsWith('metamask.')) {
111
- return `library/actions/${platform}/${action.replace(/^metamask[.]/u, '').replaceAll('.', '/')}.mjs`;
112
- }
113
- return `library/actions/${platform}/${action.replaceAll('.', '/')}.mjs`;
114
- }
115
-
116
- async function semanticResult(
117
- platform: MetaMaskRecipeAdapter,
118
- action: string,
119
- node: ActionNode,
120
- context: ActionExecutionContext,
121
- ): Promise<ActionResult> {
122
- const live = await runLiveFirst(platform, action, node, context);
123
- if (live) return live;
124
- if (requiresLiveAdapter(platform, action)) {
125
- const expected = liveAdapterPathHint(platform, action);
126
- throw new Error(
127
- `${action} requires a live ${platform} adapter that drives a real supported app/API path; ` +
128
- `no adapter script was found or no live runtime is configured (for example ${expected}). Static placeholders are refused to avoid fabricated proof. ` +
129
- 'Set METAMASK_RECIPE_LIVE_ADAPTER_DIR or add a runner library/actions script.',
130
- );
131
- }
132
- const output = { platform, action, redacted: true };
133
- if (action === 'metamask.wallet.fixture_status') return { output: fixtureSummary(context.projectRoot) };
134
- return {
135
- output: {
136
- ...output,
137
- requested: Object.fromEntries(Object.entries(node).filter(([key]) => key !== 'action')),
138
- note: 'static semantic adapter; live proof must use a real supported app/API path',
139
- },
140
- };
141
- }
142
-
143
- function isRecord(value: unknown): value is Record<string, unknown> {
144
- return Boolean(value && typeof value === 'object' && !Array.isArray(value));
145
- }
146
-
147
- function optionalScalarText(value: unknown, label: string): string | undefined {
148
- if (value === undefined || value === null) return undefined;
149
- if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
150
- return String(value);
151
- }
152
- throw new Error(`${label} must be a string, number, or boolean.`);
153
- }
154
-
155
- function scalarText(value: unknown, label: string, fallback: string): string {
156
- return optionalScalarText(value, label) ?? fallback;
157
- }
158
-
159
- function firstScalarText(
160
- record: Record<string, unknown>,
161
- keys: string[],
162
- label: string,
163
- fallback?: string,
164
- ): string {
165
- for (const key of keys) {
166
- const value = optionalScalarText(record[key], `${label}.${key}`);
167
- if (value !== undefined) return value;
168
- }
169
- if (fallback !== undefined) return fallback;
170
- throw new Error(`${label} requires one of: ${keys.join(', ')}.`);
171
- }
172
-
173
- function traceText(value: unknown): string {
174
- if (value === undefined || value === null) return '';
175
- if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
176
- return String(value);
177
- }
178
- return JSON.stringify(value);
179
- }
180
-
181
- function probeHttpJson(url: string, timeoutMs = 1000): Promise<Record<string, unknown>> {
182
- return new Promise((resolve) => {
183
- const request = http.get(url, { timeout: timeoutMs }, (response) => {
184
- let body = '';
185
- response.setEncoding('utf8');
186
- response.on('data', (chunk) => {
187
- body += chunk;
188
- });
189
- response.on('end', () => {
190
- if (!response.statusCode || response.statusCode < 200 || response.statusCode >= 300) {
191
- resolve({ reachable: false, statusCode: response.statusCode ?? null, error: `HTTP ${response.statusCode ?? 'unknown'}` });
192
- return;
193
- }
194
- try {
195
- resolve({ reachable: true, statusCode: response.statusCode, json: JSON.parse(body) });
196
- } catch (error) {
197
- resolve({
198
- reachable: true,
199
- statusCode: response.statusCode,
200
- json: null,
201
- parseError: error instanceof Error ? error.message : String(error),
202
- });
203
- }
204
- });
205
- });
206
- request.on('timeout', () => {
207
- request.destroy(new Error(`Timed out after ${timeoutMs}ms`));
208
- });
209
- request.on('error', (error) => {
210
- resolve({ reachable: false, statusCode: null, error: error.message });
211
- });
212
- });
213
- }
214
-
215
- function createMetaMaskSemanticAdapters(platform: MetaMaskRecipeAdapter): ActionAdapter[] {
216
- const walletActions = [
217
- 'metamask.wallet.fixture_status',
218
- 'metamask.wallet.setup',
219
- 'metamask.wallet.ensure_unlocked',
220
- 'metamask.wallet.select_account',
221
- 'metamask.wallet.read_state',
222
- ];
223
- const actions = [
224
- ...walletActions,
225
- 'metamask.perps.read_positions',
226
- 'metamask.perps.ensure_positions',
227
- 'metamask.perps.assert_positions',
228
- 'metamask.perps.place_order',
229
- 'metamask.perps.close_positions',
230
- 'metamask.perps.read_orders',
231
- 'metamask.perps.close_orders',
232
- 'metamask.perps.ensure_orders',
233
- 'metamask.perps.assert_orders',
234
- 'metamask.perps.start_state',
235
- 'metamask.perps.teardown_state',
236
- // read_account is core-only: only the headless core adapter implements it.
237
- ...(platform === 'core' ? ['metamask.perps.read_account'] : []),
238
- ];
239
- return actions.map((action) =>
240
- simpleAdapter(action, async (node, context) =>
241
- semanticResult(platform, action, node, context),
242
- ),
243
- );
244
- }
245
-
246
- function uiInputFor(context: ActionExecutionContext, input: MetaMaskUiActionInput) {
247
- return {
248
- action: input.action,
249
- node: input.node,
250
- context: {
251
- nodeId: context.nodeId,
252
- projectRoot: context.projectRoot,
253
- artifactsDir: context.artifactsDir,
254
- env: context.env,
255
- },
256
- };
257
- }
258
-
259
-
260
- function mobileProbeOutput(status: unknown, input: MetaMaskUiActionInput, projectRoot: string) {
261
- const entries = Array.isArray(status) ? status : [status];
262
- const preferredDevices = [
263
- input.node?.ios_simulator,
264
- input.node?.simulator,
265
- input.node?.android_device,
266
- input.node?.adb_serial,
267
- process.env.IOS_SIMULATOR,
268
- process.env.ANDROID_DEVICE,
269
- process.env.ADB_SERIAL,
270
- ].filter((value) => typeof value === 'string' && value.length > 0);
271
- const selected = entries.find((entry) => {
272
- if (!isRecord(entry)) return false;
273
- return typeof entry.deviceName === 'string' && preferredDevices.includes(entry.deviceName);
274
- }) ?? entries.find((entry) => isRecord(entry) && isRecord(entry.route)) ?? null;
275
- const route = isRecord(selected) && isRecord(selected.route) ? selected.route : null;
276
- return {
277
- reachable: Boolean(selected),
278
- bridge: mobileBridgePath(projectRoot),
279
- targetCount: entries.filter((entry) => isRecord(entry)).length,
280
- deviceName: isRecord(selected) && typeof selected.deviceName === 'string' ? selected.deviceName : null,
281
- routeName: route && typeof route.name === 'string' ? route.name : null,
282
- accountPresent: isRecord(selected) && isRecord(selected.account),
283
- };
284
- }
285
-
286
- function mobileBridgePath(_projectRoot: string): string {
287
- return process.env.METAMASK_RECIPE_MOBILE_BRIDGE_SCRIPT || 'runner:adapters/mobile/bridge-runtime/cdp-bridge.cjs';
288
- }
289
-
290
- async function waitForMobileTarget(input: ReturnType<typeof uiInputFor>, payload: ActionNode) {
291
- const timeoutMs = Number(payload.timeout_ms ?? payload.timeoutMs ?? 10000);
292
- const deadline = Date.now() + timeoutMs;
293
- const testId = firstScalarText(payload, ['test_id', 'testID'], 'Mobile ui.wait_for');
294
- const expected = scalarText(payload.expected, 'ui.wait_for.expected', 'present').toLowerCase();
295
- const expectedText = optionalScalarText(payload.text, 'ui.wait_for.text');
296
- const textMatch = scalarText(payload.text_match ?? payload.textMatch, 'ui.wait_for.text_match', 'contains').toLowerCase();
297
- const textExpression = `(function(){
298
- const api = globalThis.__AGENTIC__;
299
- if (!api?.getTextByTestId) return null;
300
- return api.getTextByTestId(${JSON.stringify(testId)});
301
- })()`;
302
- const expression = `Boolean(globalThis.__AGENTIC__?.findFiberByTestId?.(${JSON.stringify(testId)}))`;
303
- let lastValue: unknown = null;
304
- let lastText: unknown = null;
305
- const expectsAbsent = expected === 'absent' || expected === 'hidden' || expected === 'not_present';
306
- while (Date.now() <= deadline) {
307
- lastValue = await evalSync(input, expression);
308
- const present = Boolean(lastValue);
309
- if (expectsAbsent && !present) {
310
- return { matched: true, testId, expected, present };
311
- }
312
- if (!expectsAbsent && present) {
313
- if (!expectedText) {
314
- return { matched: true, testId, expected, present };
315
- }
316
- lastText = await evalSync(input, textExpression);
317
- const text = traceText(lastText);
318
- const textMatched = textMatch === 'exact' ? text === expectedText : text.includes(expectedText);
319
- if (textMatched) {
320
- return { matched: true, testId, expected, present, text, textMatch };
321
- }
322
- }
323
- await sleep(250);
324
- }
325
- const textReason = expectedText ? ` and text ${textMatch} ${JSON.stringify(expectedText)}; last text=${JSON.stringify(lastText)}` : '';
326
- throw new Error(`Timed out waiting for mobile testID ${testId} to be ${expected}${textReason}; last present=${Boolean(lastValue)}.`);
327
- }
328
-
329
- interface MobileBridgeCommand {
330
- command: string;
331
- payload: ActionNode;
332
- }
333
-
334
- type MobileBridgeHandler = (
335
- payload: ActionNode,
336
- context: ActionExecutionContext,
337
- ) => Promise<unknown>;
338
-
339
- const MOBILE_BRIDGE_HANDLERS: Record<string, MobileBridgeHandler> = {
340
- screenshot: handleMobileScreenshot,
341
- status: handleMobileStatus,
342
- navigate: handleMobileNavigate,
343
- press: handleMobilePress,
344
- setInput: handleMobileSetInput,
345
- scroll: handleMobileScroll,
346
- waitFor: handleMobileWaitFor,
347
- hud: handleMobileHud,
348
- };
349
-
350
- function createMetaMaskMobileBridge() {
351
- return {
352
- async send(command: MobileBridgeCommand, context: ActionExecutionContext) {
353
- const handler = MOBILE_BRIDGE_HANDLERS[command.command];
354
- if (!handler) {
355
- throw new Error(`React Native bridge command ${command.command} is not implemented by the MetaMask runner transport.`);
356
- }
357
- return handler(command.payload, context);
358
- },
359
- };
360
- }
361
-
362
- function mobileUiInput(context: ActionExecutionContext, command: string, payload: ActionNode) {
363
- return uiInputFor(context, { action: `ui.${command}`, node: payload });
364
- }
365
-
366
- async function handleMobileScreenshot(payload: ActionNode, context: ActionExecutionContext) {
367
- const input = mobileUiInput(context, 'screenshot', payload);
368
- const relPath = scalarText(payload.path, 'ui.screenshot.path', `screenshots/${context.nodeId}.png`);
369
- const artifact = await simulatorScreenshot(input, relPath);
370
- context.registerArtifact(artifact);
371
- return { captured: true, path: artifact.path, artifact };
372
- }
373
-
374
- async function handleMobileStatus(payload: ActionNode, context: ActionExecutionContext) {
375
- return bridgeCommand(mobileUiInput(context, 'status', payload), ['status']);
376
- }
377
-
378
- async function handleMobileNavigate(payload: ActionNode, context: ActionExecutionContext) {
379
- const live = await runLiveAdapterScript({
380
- platform: 'mobile',
381
- action: 'ui.navigate',
382
- node: payload,
383
- context,
384
- });
385
- if (!live) throw new Error('ui.navigate requires library/actions/mobile/ui/navigate.mjs.');
386
- return isRecord(live.result) ? { ...live.result, liveAdapter: live.script } : live.result;
387
- }
388
-
389
- async function handleMobilePress(payload: ActionNode, context: ActionExecutionContext) {
390
- const target = firstScalarText(payload, ['test_id', 'testID', 'selector', 'text'], 'ui.press');
391
- return bridgeCommand(mobileUiInput(context, 'press', payload), ['press-test-id', target]);
392
- }
393
-
394
-
395
- async function handleMobileSetInput(payload: ActionNode, context: ActionExecutionContext) {
396
- const input = mobileUiInput(context, 'set_input', payload);
397
- const testId = firstScalarText(payload, ['test_id', 'testID'], 'ui.set_input');
398
- const value = scalarText(payload.value ?? payload.text, 'ui.set_input.value', '');
399
- const result = await bridgeCommand(input, ['set-input', testId, value]);
400
- if (isRecord(result) && result.ok === false) {
401
- throw new Error(`ui.set_input failed for mobile testID ${testId}: ${traceText(result.error ?? result)}`);
402
- }
403
- return isRecord(result) ? result : { result, testId, value };
404
- }
405
-
406
- async function handleMobileScroll(payload: ActionNode, context: ActionExecutionContext) {
407
- const input = mobileUiInput(context, 'scroll', payload);
408
- const testId = optionalScalarText(payload.test_id ?? payload.testID, 'ui.scroll.test_id');
409
- const offset = scalarText(payload.offset ?? payload.delta_y ?? payload.deltaY, 'ui.scroll.offset', '600');
410
- const args = testId
411
- ? ['scroll-view', '--test-id', testId, '--offset', offset, animatedFlag(payload)]
412
- : ['scroll-view', '--offset', offset, animatedFlag(payload)];
413
- const result = await bridgeCommand(input, args);
414
- return {
415
- ...(isRecord(result) ? result : { result }),
416
- intoView: payload.scroll_into_view === true || payload.into_view === true,
417
- };
418
- }
419
-
420
- async function handleMobileWaitFor(payload: ActionNode, context: ActionExecutionContext) {
421
- return waitForMobileTarget(mobileUiInput(context, 'waitFor', payload), payload);
422
- }
423
-
424
- async function handleMobileHud(payload: ActionNode, context: ActionExecutionContext) {
425
- const input = mobileUiInput(context, 'hud', payload);
426
- if (payload.clear === true) return bridgeCommand(input, ['hide-step']);
427
- const hud = mobileHudPayload(payload, context);
428
- const result = await bridgeCommand(input, ['show-step-json', JSON.stringify(hud.step)]);
429
- return { hud: true, nodeId: hud.nodeId, status: hud.status, result };
430
- }
431
-
432
- function animatedFlag(payload: ActionNode): string {
433
- return payload.animated === true ? '--animated' : '--no-animated';
434
- }
435
-
436
- function mobileHudPayload(payload: ActionNode, context: ActionExecutionContext) {
437
- const nodeId = scalarText(payload.node_id ?? payload.nodeId, 'app.hud.node_id', context.nodeId);
438
- const status = scalarText(payload.status, 'app.hud.status', 'running');
439
- const text = optionalScalarText(payload.intent ?? payload.text ?? payload.detail, 'app.hud.intent');
440
- if (!text || !text.trim()) {
441
- throw new Error('app.hud requires intent/text/detail; automatic recipe progress should provide node intent.');
442
- }
443
- const detail = scalarText(payload.detail, 'app.hud.detail', '');
444
- const error = scalarText(payload.error, 'app.hud.error', '');
445
- const progress = mobileHudProgress(payload.progress);
446
- const display = isRecord(payload.display) ? payload.display : {};
447
- const showDebug = display.showDebug === true;
448
- const showDetail = display.showDetail === true;
449
- const proofTarget = payload.proofTarget ?? payload.proof_target;
450
- const displayId = [mobileHudStatusLabel(status), progress.text].filter(Boolean).join(' ');
451
- return {
452
- nodeId,
453
- status,
454
- step: {
455
- id: displayId || nodeId,
456
- nodeId,
457
- status,
458
- intent: text,
459
- progress: progress.value,
460
- detail: showDetail && detail !== text ? detail : undefined,
461
- error: error || undefined,
462
- debug: showDebug
463
- ? {
464
- nodeId,
465
- proofTarget,
466
- }
467
- : undefined,
468
- },
469
- };
470
- }
471
-
472
- function mobileHudProgress(progress: unknown): {
473
- text: string;
474
- value?: { current: number; total: number };
475
- } {
476
- if (!isRecord(progress)) return { text: '' };
477
- if (typeof progress.current !== 'number' || typeof progress.total !== 'number') return { text: '' };
478
- return {
479
- text: `${progress.current}/${progress.total}`,
480
- value: { current: progress.current, total: progress.total },
481
- };
482
- }
483
-
484
- function mobileHudStatusLabel(status: string): string {
485
- if (status === 'fail') return 'fail';
486
- if (status === 'pass') return 'pass';
487
- return 'run';
488
- }
489
-
490
- export function createMetaMaskUiTransport(
491
- platform: MetaMaskRecipeAdapter,
492
- harness: {
493
- createReactNativeBridgeUiTransport: (options: unknown) => UiActionTransport;
494
- createCdpWebUiTransport: (options: unknown) => UiActionTransport;
495
- },
496
- ): UiActionTransport {
497
- if (platform === 'core') {
498
- // Headless core has no UI surface. The core manifest declares no ui.* actions,
499
- // so this transport is never exercised; reject explicitly if a recipe adds one.
500
- return {
501
- async execute(action) {
502
- throw new Error(
503
- `core adapter is headless and cannot execute UI action ${action}. ` +
504
- 'Use a perps read action or run the recipe against the mobile/extension adapter.',
505
- );
506
- },
507
- };
508
- }
509
- const base =
510
- platform === 'mobile'
511
- ? harness.createReactNativeBridgeUiTransport({ bridge: createMetaMaskMobileBridge() })
512
- : harness.createCdpWebUiTransport({
513
- async withPage(input: { action: string; node: ActionNode; context: ActionExecutionContext }, callback: (page: unknown) => Promise<unknown>) {
514
- return withExtensionPage(
515
- {
516
- action: input.action,
517
- node: input.node,
518
- context: {
519
- nodeId: input.context.nodeId,
520
- projectRoot: input.context.projectRoot,
521
- artifactsDir: input.context.artifactsDir,
522
- },
523
- },
524
- callback,
525
- );
526
- },
527
- });
528
- return {
529
- async execute(action, node, context) {
530
- if (action === 'ui.navigate') {
531
- const live = await runLiveFirst(platform, action, node, context);
532
- if (live) return live.output;
533
- throw new Error(`ui.navigate requires library/actions/${platform}/ui/navigate.mjs.`);
534
- }
535
- return base.execute(action, node, context);
536
- },
537
- };
538
- }
539
-
540
- export function createMetaMaskAdapters(adapter: MetaMaskRecipeAdapter): ActionAdapter[] {
541
- const platform = adapter;
542
- const adapters = [
543
- simpleAdapter('app.status', async (_node, context) => ({
544
- output: {
545
- platform,
546
- projectRoot: context.projectRoot,
547
- compatibilityMode: compatibilityMode(adapter, context.projectRoot),
548
- shape: repoShape(context.projectRoot),
549
- },
550
- })),
551
- simpleAdapter('cdp.target', async (node, context) => {
552
- const cdpPort = node.cdp_port ?? process.env.CDP_PORT ?? process.env.RECIPE_CDP_PORT ?? null;
553
- const metroPort = node.metro_port ?? node.watcher_port ?? process.env.WATCHER_PORT ?? null;
554
- const timeoutMs = Number(node.probe_timeout_ms ?? node.cdp_timeout_ms ?? node.timeout_ms ?? process.env.CDP_TIMEOUT ?? 10000);
555
- const required = node.require_reachable === true || node.required === true;
556
- if (platform === 'mobile' && required) {
557
- const uiInput: MetaMaskUiActionInput = { action: 'cdp.target', nodeId: context.nodeId, node };
558
- const status = await bridgeCommand(uiInputFor(context, uiInput), ['status']);
559
- const probe = mobileProbeOutput(status, uiInput, context.projectRoot);
560
- if (!probe.reachable) {
561
- throw new Error('cdp.target required a reachable mobile React Native bridge, but bridge status did not expose a selected app route.');
562
- }
563
- return {
564
- output: {
565
- platform,
566
- transport: 'react-native-debug-bridge',
567
- cdpPort,
568
- metroPort,
569
- timeoutMs,
570
- ...probe,
571
- },
572
- };
573
- }
574
- const targetPort = platform === 'mobile' ? metroPort ?? cdpPort : cdpPort;
575
- const url = targetProbeUrl(platform, targetPort);
576
- const probe = url ? await probeHttpJson(url, timeoutMs) : { reachable: null, statusCode: null, error: 'no port declared' };
577
- if (required && !probe.reachable) {
578
- throw new Error(`cdp.target required a reachable ${platform} runtime at ${url ?? '<no port>'}: ${traceText(probe.error) || 'not reachable'}`);
579
- }
580
- return {
581
- output: {
582
- platform,
583
- transport: platform === 'mobile' ? 'react-native-debug-bridge' : 'chrome-extension-cdp',
584
- cdpPort,
585
- metroPort,
586
- probeUrl: url,
587
- timeoutMs,
588
- ...probe,
589
- },
590
- };
591
- }),
592
- ...createMetaMaskSemanticAdapters(platform),
593
- ];
594
- return adapters;
595
- }
596
-
597
- function targetProbeUrl(platform: MetaMaskRecipeAdapter, targetPort: unknown): string | null {
598
- if (!targetPort) return null;
599
- const pathSuffix = platform === 'mobile' ? 'json/list' : 'json/version';
600
- return `http://127.0.0.1:${traceText(targetPort)}/${pathSuffix}`;
601
- }
package/src/cli-color.ts DELETED
@@ -1,92 +0,0 @@
1
- /**
2
- * cli-color.ts — shared ANSI styling for recipe CLI human output.
3
- *
4
- * Honors NO_COLOR, RECIPE_NO_COLOR, FORCE_COLOR, RECIPE_COLOR, and TTY detection.
5
- */
6
-
7
- type StyleName =
8
- | 'reset' | 'bold' | 'dim' | 'label' | 'cmd' | 'ok' | 'warn'
9
- | 'err' | 'info' | 'accent' | 'path' | 'comment' | 'active';
10
-
11
- const STYLES: Record<StyleName, string> = {
12
- reset: '\x1b[0m',
13
- bold: '\x1b[1m',
14
- dim: '\x1b[2m',
15
- label: '\x1b[1;36m',
16
- cmd: '\x1b[1m',
17
- ok: '\x1b[1;32m',
18
- warn: '\x1b[1;33m',
19
- err: '\x1b[1;31m',
20
- info: '\x1b[0;37m',
21
- accent: '\x1b[1;35m',
22
- path: '\x1b[2m',
23
- comment: '\x1b[2m',
24
- active: '\x1b[1m',
25
- };
26
-
27
- export function colorEnabled(stream: NodeJS.WriteStream = process.stderr): boolean {
28
- if (process.env.NO_COLOR != null && process.env.NO_COLOR !== '' && process.env.NO_COLOR !== '0') {
29
- return false;
30
- }
31
- if (process.env.RECIPE_NO_COLOR === '1') return false;
32
- if (process.env.RECIPE_COLOR === '1' || process.env.FORCE_COLOR === '1') return true;
33
- return Boolean((stream as NodeJS.WriteStream & { isTTY?: boolean })?.isTTY);
34
- }
35
-
36
- export function stripAnsi(text: string): string {
37
- return String(text).replace(/\x1b\[[0-9;]*m/gu, '');
38
- }
39
-
40
- export function color(style: string, text: string, { stream = process.stderr }: { stream?: NodeJS.WriteStream } = {}): string {
41
- const value = String(text);
42
- if (!colorEnabled(stream)) return value;
43
- const code = STYLES[style as StyleName];
44
- if (!code) return value;
45
- return `${code}${value}${STYLES.reset}`;
46
- }
47
-
48
- export function colorKv(
49
- label: string,
50
- value: string,
51
- valueStyle = 'info',
52
- { stream = process.stderr }: { stream?: NodeJS.WriteStream } = {},
53
- ): string {
54
- return `${color('label', `${label}:`, { stream })} ${color(valueStyle, value, { stream })}`;
55
- }
56
-
57
- export function colorStatusWord(word: string, { stream = process.stderr }: { stream?: NodeJS.WriteStream } = {}): string {
58
- const normalized = String(word || '').toLowerCase();
59
- if (['up', 'ready', 'yes', 'pass', 'running', 'present', 'healthy'].includes(normalized)) {
60
- return color('ok', word, { stream });
61
- }
62
- if (['down', 'stopped', 'no', 'fail', 'failed', 'missing', 'blocked', 'unknown'].includes(normalized)) {
63
- return color('err', word, { stream });
64
- }
65
- if (['compiling', 'bundling', 'starting', 'waiting', 'warn'].includes(normalized)) {
66
- return color('warn', word, { stream });
67
- }
68
- return color('info', word, { stream });
69
- }
70
-
71
- export function classifyLogEvent(event: string): string {
72
- const text = String(event || '');
73
- if (/Module build failed|^ERROR in |BUILD FAILED|compiled with [1-9][0-9]* error/iu.test(text)) {
74
- return 'err';
75
- }
76
- if (/compiled successfully|compiled with [0-9]+ warning|PASS runtime-launch|launch pass|verify pass|build complete/iu.test(text)) {
77
- return 'ok';
78
- }
79
- if (/phase [0-9]\/[0-9]|prepare pipeline|snapshot|fixture:|dist-freshness|build-health/iu.test(text)) {
80
- return 'label';
81
- }
82
- if (/webpack [0-9]+%|Bundl(ed|ing)|(iOS|Android).*%/iu.test(text)) {
83
- return 'warn';
84
- }
85
- return 'info';
86
- }
87
-
88
- export function colorLogEvent(event: string, { latest = false, stream = process.stderr }: { latest?: boolean; stream?: NodeJS.WriteStream } = {}): string {
89
- const style = classifyLogEvent(event);
90
- const painted = color(style === 'label' ? 'label' : style, event, { stream });
91
- return latest ? color('active', painted, { stream }) : painted;
92
- }