@farmslot/adapter-web 0.5.0 → 0.6.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 (43) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +25 -1
  3. package/dist/web-dapp/cleanup.d.mts +15 -0
  4. package/dist/web-dapp/index.d.mts +124 -0
  5. package/dist/web-dapp/inject.d.mts +12 -0
  6. package/dist/web-dapp/launch.d.mts +57 -0
  7. package/dist/web-dapp/lib/blocked-hosts.d.mts +3 -0
  8. package/dist/web-dapp/lib/cdp-client.d.mts +8 -0
  9. package/dist/web-dapp/lib/display.d.mts +26 -0
  10. package/dist/web-dapp/lib/hud.d.mts +14 -0
  11. package/dist/web-dapp/lib/paths.d.mts +11 -0
  12. package/dist/web-dapp/lib/policy-fence.d.mts +18 -0
  13. package/dist/web-dapp/lib/policy.d.mts +4 -0
  14. package/dist/web-dapp/lib/processes.d.mts +57 -0
  15. package/dist/web-dapp/lib/readiness.d.mts +85 -0
  16. package/dist/web-dapp/lib/runtime.d.mts +29 -0
  17. package/dist/web-dapp/lib/session-lock.d.mts +21 -0
  18. package/dist/web-dapp/lib/signers.d.mts +13 -0
  19. package/dist/web-dapp/lib/viem.d.mts +1 -0
  20. package/dist/web-dapp/stop.d.mts +2 -0
  21. package/dist/web-dapp/verify.d.mts +2 -0
  22. package/dist/web-dapp/wallet-host.d.mts +2 -0
  23. package/package.json +17 -10
  24. package/src/web-dapp/cleanup.mjs +74 -0
  25. package/src/web-dapp/index.mjs +596 -0
  26. package/src/web-dapp/inject.mjs +100 -0
  27. package/src/web-dapp/launch.mjs +818 -0
  28. package/src/web-dapp/lib/blocked-hosts.mjs +24 -0
  29. package/src/web-dapp/lib/cdp-client.mjs +86 -0
  30. package/src/web-dapp/lib/display.mjs +169 -0
  31. package/src/web-dapp/lib/hud.mjs +156 -0
  32. package/src/web-dapp/lib/paths.mjs +55 -0
  33. package/src/web-dapp/lib/policy-fence.mjs +128 -0
  34. package/src/web-dapp/lib/policy.mjs +108 -0
  35. package/src/web-dapp/lib/processes.mjs +215 -0
  36. package/src/web-dapp/lib/readiness.mjs +438 -0
  37. package/src/web-dapp/lib/runtime.mjs +195 -0
  38. package/src/web-dapp/lib/session-lock.mjs +56 -0
  39. package/src/web-dapp/lib/signers.mjs +105 -0
  40. package/src/web-dapp/lib/viem.mjs +26 -0
  41. package/src/web-dapp/stop.mjs +44 -0
  42. package/src/web-dapp/verify.mjs +68 -0
  43. package/src/web-dapp/wallet-host.mjs +728 -0
@@ -0,0 +1,596 @@
1
+ // @ts-check
2
+ // The generic web-dapp lifecycle as an adapter-sdk PlatformAdapter: a web app
3
+ // under test whose dev server the slot owns; the adapter owns the slot browser
4
+ // (an extension signer or the injected strict wallet) and its wallet host. What
5
+ // it knows about the app's venue comes from the venue policy of the adapter that
6
+ // extends it, bound into RECIPE_WEB_DAPP_POLICY by `bindWebDappPolicy`. Selected
7
+ // on its own, web-dapp has no policy: launch, verify and runtime status refuse,
8
+ // doctor reports it.
9
+ //
10
+ // The leaf scripts (launch, wallet-host, inject, verify, stop, cleanup) are CLIs
11
+ // that run as their own node processes; `webDappLeafPath` names them.
12
+
13
+ import { execFileSync, spawnSync } from 'node:child_process';
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+ import { fileURLToPath } from 'node:url';
17
+
18
+ import { recipeRuntimePath, shellQuote } from './lib/paths.mjs';
19
+ import { POLICY_ENV, webDappAdapterId, webDappPolicy } from './lib/policy.mjs';
20
+ import { check, webDappReadiness } from './lib/readiness.mjs';
21
+ import { defaultSigner, loadSigners, SIGNER_MODULE_ENV } from './lib/signers.mjs';
22
+ import { resolveBrowser } from './launch.mjs';
23
+
24
+ export {
25
+ loadSigners,
26
+ POLICY_ENV,
27
+ check as readinessCheck,
28
+ SIGNER_MODULE_ENV,
29
+ webDappAdapterId,
30
+ webDappPolicy,
31
+ };
32
+ export { cleanupWebDappRuntime } from './cleanup.mjs';
33
+ export { installWebDappRuntime } from './inject.mjs';
34
+ export {
35
+ backgroundWindowParams,
36
+ checkFocusAfterLaunch,
37
+ chromeForTestingCandidates,
38
+ LAUNCH_SERVICES,
39
+ LAUNCH_TIMEOUTS,
40
+ launchMethodFor,
41
+ launchWebDappBrowser,
42
+ macApplicationForExecutable,
43
+ parseLaunchArgs,
44
+ resolveBrowser,
45
+ SPAWN,
46
+ spawnDetached,
47
+ } from './launch.mjs';
48
+ export { hostOf, hostResolverRules, isBlockedUrl } from './lib/blocked-hosts.mjs';
49
+ export { browserWebSocketUrl, CdpClient } from './lib/cdp-client.mjs';
50
+ export {
51
+ recipeHarnessPath,
52
+ recipeRuntimeDir,
53
+ recipeRuntimePath,
54
+ shellQuote,
55
+ walletFixturePath,
56
+ } from './lib/paths.mjs';
57
+ export {
58
+ assertPolicyDigest,
59
+ fencePolicy,
60
+ POLICY_DIGEST_ENV,
61
+ PolicyFenceError,
62
+ } from './lib/policy-fence.mjs';
63
+ export {
64
+ cdpListenerPids,
65
+ ownedBrowserPids,
66
+ OWNER_FLAG,
67
+ OWNER_KINDS,
68
+ ownerMarker,
69
+ ownsProcess,
70
+ pidAlive,
71
+ portFree,
72
+ stopConsoleCollector,
73
+ stopWebDappBrowser,
74
+ terminate,
75
+ } from './lib/processes.mjs';
76
+ export {
77
+ assertLaunchNetwork,
78
+ DEV_SERVER_PID_FILE,
79
+ devServerTestnetDiagnostic,
80
+ pythonFor,
81
+ webDappReadiness,
82
+ } from './lib/readiness.mjs';
83
+ export {
84
+ accountName,
85
+ appOrigin,
86
+ appPort,
87
+ canonicalPath,
88
+ cdpPort,
89
+ DEFAULT_ACCOUNT,
90
+ fixtureAccount,
91
+ fixtureEntry,
92
+ fixturePath,
93
+ loadWalletFixture,
94
+ readBrowserState,
95
+ readJsonFile,
96
+ resolveAccountAddress,
97
+ resolveSigner,
98
+ SIGNER_MODES,
99
+ viemAccountFromEntry,
100
+ webDappRuntimeDir,
101
+ webDappRuntimePath,
102
+ writePrivateJson,
103
+ } from './lib/runtime.mjs';
104
+ export {
105
+ macosSessionLocked,
106
+ parseSessionLocked,
107
+ SESSION_LOCKED_MESSAGE,
108
+ SESSION_LOCKED_USER_ACTION,
109
+ } from './lib/session-lock.mjs';
110
+
111
+ const LEAVES = Object.freeze(['launch', 'wallet-host', 'inject', 'verify', 'stop', 'cleanup']);
112
+
113
+ /**
114
+ * The absolute path of a leaf script (`launch`, `wallet-host`, `inject`,
115
+ * `verify`, `stop` or `cleanup`).
116
+ * @param {'launch' | 'wallet-host' | 'inject' | 'verify' | 'stop' | 'cleanup'} name
117
+ */
118
+ export function webDappLeafPath(name) {
119
+ if (!LEAVES.includes(name))
120
+ throw new Error(`unknown web-dapp leaf ${JSON.stringify(name)}; one of ${LEAVES.join(', ')}.`);
121
+ return fileURLToPath(new URL(`./${name}.mjs`, import.meta.url));
122
+ }
123
+
124
+ // Console files, next to the slot browser's other runtime files:
125
+ // app-console.log the app page(s): console calls and uncaught exceptions
126
+ // extension-console.log the wallet extension's service worker and UI pages (signer=extension)
127
+ export const WEB_DAPP_CONSOLE_FILES = Object.freeze({
128
+ app: 'app-console.log',
129
+ extension: 'extension-console.log',
130
+ collectorLog: 'console-tail.log',
131
+ pidFile: 'console-tail.pid',
132
+ });
133
+
134
+ /**
135
+ * A file in the slot's runtime directory (the realpath of the target, as the
136
+ * launcher uses): <checkout>/<runtime>/<adapter id>/<name>.
137
+ * @param {string} target
138
+ * @param {string} name
139
+ */
140
+ export function webDappRuntimeFile(target, name) {
141
+ let root = path.resolve(target);
142
+ try {
143
+ root = fs.realpathSync(root);
144
+ } catch {
145
+ // A missing checkout keeps the resolved path; callers report it.
146
+ }
147
+ return recipeRuntimePath(root, webDappAdapterId(), name);
148
+ }
149
+
150
+ // Flags only the web-dapp launcher reads; other adapters reject them instead
151
+ // of silently ignoring them.
152
+ export const WEB_DAPP_ONLY_LAUNCH_OPTIONS = Object.freeze({
153
+ signer: '--signer',
154
+ account: '--account',
155
+ freshProfile: '--fresh-profile',
156
+ headless: '--headless',
157
+ headful: '--headful',
158
+ slowMo: '--slow-mo',
159
+ network: '--network',
160
+ mainnetConfirmation: '--mainnet-confirmation',
161
+ });
162
+
163
+ /** @param {Record<string, string | boolean>} options */
164
+ export function webDappOnlyLaunchFlags(options) {
165
+ return Object.entries(WEB_DAPP_ONLY_LAUNCH_OPTIONS)
166
+ .filter(([key]) => options[key] !== undefined && options[key] !== false)
167
+ .map(([, name]) => name);
168
+ }
169
+
170
+ /**
171
+ * The leaf prints one JSON document (compact with --json, indented without).
172
+ * Parse it whole; fall back to the last line for any log noise before it.
173
+ * @param {string} output
174
+ * @returns {Record<string, unknown>}
175
+ */
176
+ export function parseWebDappLauncherOutput(output) {
177
+ const text = output.trim();
178
+ if (!text)
179
+ return { status: 'fail', error: `the ${webDappAdapterId()} launcher printed no summary` };
180
+ try {
181
+ return JSON.parse(text);
182
+ } catch {
183
+ const last =
184
+ text
185
+ .split('\n')
186
+ .filter((line) => line.trim())
187
+ .pop() ?? '';
188
+ try {
189
+ return JSON.parse(last);
190
+ } catch {
191
+ return {
192
+ status: 'fail',
193
+ error: `the ${webDappAdapterId()} launcher printed no JSON summary: ${last.slice(0, 200)}`,
194
+ };
195
+ }
196
+ }
197
+ }
198
+
199
+ /** @param {...string} names */
200
+ function portFromEnv(...names) {
201
+ for (const name of names) {
202
+ const raw = process.env[name];
203
+ if (raw && /^\d+$/u.test(raw)) return Number(raw);
204
+ }
205
+ return undefined;
206
+ }
207
+
208
+ export function webDappAppPort() {
209
+ return portFromEnv('TERMINAL_APP_PORT', 'RECIPE_WATCHER_PORT', 'WATCHER_PORT');
210
+ }
211
+
212
+ export function webDappCdpPort() {
213
+ return portFromEnv('RECIPE_CDP_PORT', 'CDP_PORT');
214
+ }
215
+
216
+ // The running browser's signer, else the requested one, else the default.
217
+ /**
218
+ * @param {string} target
219
+ * @param {string} fallback
220
+ */
221
+ function webDappSignerFor(target, fallback) {
222
+ try {
223
+ const state = JSON.parse(fs.readFileSync(webDappRuntimeFile(target, 'browser.json'), 'utf8'));
224
+ if (typeof state.signer === 'string') return state.signer;
225
+ } catch {
226
+ // No running slot browser.
227
+ }
228
+ return process.env.TERMINAL_SIGNER || fallback;
229
+ }
230
+
231
+ /**
232
+ * Bind the venue policy of an adapter that extends web-dapp: its module path
233
+ * goes to RECIPE_WEB_DAPP_POLICY, which web-dapp's members, leaf processes and
234
+ * live actions read for this command. Called when the host adopts the adapter.
235
+ * @template {{ extends?: string, policy?: { module?: unknown } }} T
236
+ * @param {T} adapter
237
+ * @returns {T}
238
+ */
239
+ export function bindWebDappPolicy(adapter) {
240
+ if (adapter.extends === 'web-dapp' && typeof adapter.policy?.module === 'string') {
241
+ process.env[POLICY_ENV] = adapter.policy.module;
242
+ }
243
+ return adapter;
244
+ }
245
+
246
+ // Mainnet attempts the slot browser blocked or the wallet refused, and wallet
247
+ // log entries that could not be attributed, during the run (whatever a
248
+ // read_signatures cursor later skips). The launch's own probes are excluded.
249
+ /**
250
+ * @param {readonly string[]} lines
251
+ * @returns {import('@farmslot/adapter-sdk').AdapterLogFinding[]}
252
+ */
253
+ export function walletRequestFindings(lines) {
254
+ /** @type {import('@farmslot/adapter-sdk').AdapterLogFinding[]} */
255
+ const findings = [];
256
+ for (const line of lines) {
257
+ let entry;
258
+ try {
259
+ entry = JSON.parse(line);
260
+ } catch {
261
+ continue;
262
+ }
263
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) continue;
264
+ let text = null;
265
+ if (entry.kind === 'blocked-mainnet' && entry.probe !== true)
266
+ text = `blocked-mainnet: ${String(entry.transport ?? 'request')} to ${String(entry.url ?? '?')}`;
267
+ else if (entry.kind === 'refused-mainnet')
268
+ text = `refused-mainnet: ${String(entry.method ?? '?')} (${String(entry.reason ?? '?')})`;
269
+ else if (entry.kind === 'unattributed')
270
+ text = `unattributed wallet ${entry.binding === 'request' ? 'request' : 'log entry'}${typeof entry.method === 'string' ? ` (${entry.method})` : ''}`;
271
+ if (text) findings.push({ level: 'error', source: 'wallet', text });
272
+ }
273
+ return findings;
274
+ }
275
+
276
+ /**
277
+ * @param {Record<string, string | boolean>} options
278
+ * @param {string} key
279
+ */
280
+ const str = (options, key) => (typeof options[key] === 'string' ? options[key] : undefined);
281
+ /**
282
+ * @param {Record<string, string | boolean>} options
283
+ * @param {string} key
284
+ */
285
+ const flag = (options, key) => options[key] === true;
286
+
287
+ /**
288
+ * @typedef {Partial<Omit<import('@farmslot/adapter-sdk').PlatformAdapter, 'id' | 'sdkVersion' | 'harness'>> & {
289
+ * harness?: Partial<import('@farmslot/adapter-sdk').AdapterHarness>,
290
+ * }} WebDappAdapterHooks
291
+ */
292
+
293
+ /**
294
+ * @typedef {object} WebDappAdapterOptions
295
+ * @property {string} [id] The adapter id; web-dapp by default.
296
+ * @property {string} [signerModule] Absolute path of the ESM module exporting
297
+ * `signers` (see lib/signers.mjs). The one source of the signer hooks: readiness
298
+ * loads it in this process and launch, verify and the wallet host load it in theirs.
299
+ * @property {string} [cli] The host's bin name, for `Next:` lines. farmslot-recipe by default.
300
+ * @property {WebDappAdapterHooks} [hooks]
301
+ * Anything of the PlatformAdapter the host supplies or replaces. `actions` is
302
+ * required in practice (web-dapp ships no action manifest); `diagnostics`,
303
+ * `readiness` and `harness` merge over the generic members, the rest replace.
304
+ */
305
+
306
+ /**
307
+ * @param {WebDappAdapterOptions} [factoryOptions]
308
+ * @returns {import('@farmslot/adapter-sdk').PlatformAdapter}
309
+ */
310
+ export function createWebDappAdapter(factoryOptions = {}) {
311
+ if ('signers' in factoryOptions) {
312
+ throw new Error(
313
+ 'createWebDappAdapter no longer takes `signers`: pass `signerModule`, the path of the module exporting them, so readiness, launch, verify and the wallet host load the same hooks.',
314
+ );
315
+ }
316
+ const { id = 'web-dapp', signerModule, cli = 'farmslot-recipe', hooks = {} } = factoryOptions;
317
+ const { actions, diagnostics, readiness, harness, ...rest } = hooks;
318
+ const resolveSigners = async () =>
319
+ loadSigners(signerModule ? { ...process.env, [SIGNER_MODULE_ENV]: signerModule } : process.env);
320
+ const venueHint = `pass --adapter <the adapter that extends ${id}>, e.g. --adapter terminal, with its recipe library on RECIPE_LIBRARY_PATH`;
321
+
322
+ return {
323
+ id,
324
+ sdkVersion: 1,
325
+ headless: false,
326
+
327
+ resolveSlotPorts() {
328
+ // Ports come from the slot (Farmslot {{port}} / {{cdp_port}}) through
329
+ // --watcher-port / --cdp-port or the runtime context; nothing is claimed here.
330
+ },
331
+
332
+ async runtimeStatus(target) {
333
+ if (!process.env[POLICY_ENV]) {
334
+ return {
335
+ decision: 'blocked',
336
+ reasonCode: 'web-dapp-venue-policy',
337
+ reasons: [`${id} needs a venue policy: select an adapter that extends it`],
338
+ nextAction: venueHint,
339
+ deps: 'missing',
340
+ devServer: { label: 'next', status: 'down' },
341
+ };
342
+ }
343
+ const policy = webDappPolicy();
344
+ const report = await webDappReadiness({
345
+ policy,
346
+ signers: await resolveSigners(),
347
+ target: path.resolve(target),
348
+ appPort: webDappAppPort(),
349
+ cdpPort: webDappCdpPort(),
350
+ signer: process.env.TERMINAL_SIGNER || defaultSigner(signerModule),
351
+ });
352
+ const blocking = report.checks.filter((item) => item.required && item.status === 'fail');
353
+ const devServer = report.checks.find((item) => item.id === 'app-dev-server');
354
+ return {
355
+ decision: blocking.length === 0 ? 'ready' : 'blocked',
356
+ reasonCode: blocking[0]?.id,
357
+ reasons: (blocking.length ? blocking : report.checks).map(
358
+ (item) => `${item.id}: ${item.detail}`,
359
+ ),
360
+ nextAction:
361
+ blocking.length === 0
362
+ ? undefined
363
+ : blocking[0].id === 'app-dev-server' || blocking[0].id === 'testnet-forced'
364
+ ? 'start the slot dev server with testnet forced (Farmslot preflight), then retry'
365
+ : `${cli} doctor --adapter ${policy.adapterId} --target ${shellQuote(path.resolve(target))} --json`,
366
+ deps:
367
+ report.checks.find((item) => item.id === 'dependencies')?.status === 'pass'
368
+ ? 'current'
369
+ : 'missing',
370
+ devServer: { label: 'next', status: devServer?.status === 'pass' ? 'up' : 'down' },
371
+ };
372
+ },
373
+
374
+ devServer: {
375
+ label: 'browser',
376
+ describe: () =>
377
+ `${webDappAdapterId()} slot browser and wallet host (the Next.js dev server belongs to the slot)`,
378
+ stop(target) {
379
+ const args = [webDappLeafPath('stop'), '--target', path.resolve(target)];
380
+ const cdpPort = process.env.RECIPE_CDP_PORT ?? process.env.CDP_PORT;
381
+ if (cdpPort) args.push('--cdp-port', cdpPort);
382
+ const result = spawnSync(process.execPath, args, { encoding: 'utf8' });
383
+ const output = `${result.stdout ?? ''}${result.stderr ?? ''}`;
384
+ const status = result.status ?? 1;
385
+ return {
386
+ kind: 'stopped',
387
+ status,
388
+ summary:
389
+ status === 0
390
+ ? `${webDappAdapterId()} browser and wallet host stopped`
391
+ : `${webDappAdapterId()} browser stop failed`,
392
+ output,
393
+ };
394
+ },
395
+ },
396
+
397
+ logSources(target) {
398
+ return [
399
+ { label: 'app-console', path: webDappRuntimeFile(target, WEB_DAPP_CONSOLE_FILES.app) },
400
+ ...(webDappSignerFor(target, defaultSigner(signerModule)) === 'extension'
401
+ ? [
402
+ {
403
+ label: 'extension-console',
404
+ path: webDappRuntimeFile(target, WEB_DAPP_CONSOLE_FILES.extension),
405
+ },
406
+ ]
407
+ : []),
408
+ { label: 'wallet-requests', path: webDappRuntimeFile(target, 'wallet-requests.jsonl') },
409
+ { label: 'wallet-host', path: webDappRuntimeFile(target, 'wallet-host.log') },
410
+ { label: 'browser', path: webDappRuntimeFile(target, 'browser.log') },
411
+ { label: 'next', path: path.join(target, 'temp/farmslot/next-dev.log') },
412
+ ];
413
+ },
414
+
415
+ // The app page's console and uncaught exceptions: errors make a run REVIEW.
416
+ appLogSource(target) {
417
+ return { label: 'app-console', path: webDappRuntimeFile(target, WEB_DAPP_CONSOLE_FILES.app) };
418
+ },
419
+
420
+ flags: { launch: ['freshProfile', 'headless', 'headful'] },
421
+ // Bare web-dapp has no venue: every hint names the adapter that extends it.
422
+ hints: {
423
+ launch: venueHint,
424
+ relaunch: `pass --adapter <the adapter that extends ${id}>, e.g. --adapter terminal, with --fresh-profile`,
425
+ runtimeProbeRecovery: () => venueHint,
426
+ },
427
+
428
+ // Start (or reuse) the slot browser with the requested signer. The app's dev
429
+ // server belongs to the slot and is never started here.
430
+ async launch({ options, target, jsonOutput, stream, usage }) {
431
+ const adapterId = webDappAdapterId();
432
+ const cdpPort =
433
+ str(options, 'cdpPort') ?? process.env.RECIPE_CDP_PORT ?? process.env.CDP_PORT;
434
+ // Same precedence as the web-dapp surface and live actions.
435
+ const appPort =
436
+ str(options, 'watcherPort') ??
437
+ process.env.TERMINAL_APP_PORT ??
438
+ process.env.RECIPE_WATCHER_PORT ??
439
+ process.env.WATCHER_PORT;
440
+ if (!cdpPort || !appPort) {
441
+ return usage(
442
+ `${adapterId} launch needs the slot browser CDP port and the slot dev-server port.`,
443
+ `${cli} launch --adapter ${adapterId} --cdp-port <port> --watcher-port <app port>`,
444
+ );
445
+ }
446
+ const args = [
447
+ webDappLeafPath('launch'),
448
+ '--target',
449
+ target,
450
+ '--cdp-port',
451
+ cdpPort,
452
+ '--app-port',
453
+ appPort,
454
+ '--signer',
455
+ str(options, 'signer') ?? process.env.TERMINAL_SIGNER ?? defaultSigner(signerModule),
456
+ '--account',
457
+ str(options, 'account') ?? process.env.TERMINAL_ACCOUNT ?? 'dev1',
458
+ '--json',
459
+ ];
460
+ if (signerModule) args.push('--signer-module', signerModule);
461
+ if (flag(options, 'freshProfile')) args.push('--fresh-profile');
462
+ if (flag(options, 'headless')) args.push('--headless');
463
+ if (flag(options, 'headful')) args.push('--headful');
464
+ const slowMo = str(options, 'slowMo');
465
+ if (slowMo) args.push('--slow-mo', slowMo);
466
+ const network = str(options, 'network');
467
+ if (network) args.push('--network', network);
468
+ const mainnetConfirmation = str(options, 'mainnetConfirmation');
469
+ if (mainnetConfirmation) args.push('--mainnet-confirmation', mainnetConfirmation);
470
+ stream.phase('launch', { adapter: adapterId, cdpPort, appPort });
471
+ let output = '';
472
+ let status = 0;
473
+ try {
474
+ output = execFileSync(process.execPath, args, {
475
+ encoding: 'utf8',
476
+ stdio: ['ignore', 'pipe', 'inherit'],
477
+ });
478
+ } catch (error) {
479
+ status = 1;
480
+ output = String(/** @type {{ stdout?: string }} */ (error)?.stdout ?? '');
481
+ }
482
+ const result =
483
+ /** @type {{ status?: string, reused?: boolean, cdpPort?: number, signer?: string, account?: { name?: string }, error?: string }} */ (
484
+ parseWebDappLauncherOutput(output)
485
+ );
486
+ if (jsonOutput)
487
+ console.log(
488
+ JSON.stringify(
489
+ { schemaVersion: 1, command: 'launch', adapter: adapterId, target, ...result },
490
+ null,
491
+ 2,
492
+ ),
493
+ );
494
+ else if (result.status === 'pass')
495
+ console.log(
496
+ `✓ ${adapterId} browser ${result.reused ? 'reused' : 'started'} on CDP ${result.cdpPort} (${result.signer}, ${result.account?.name})`,
497
+ );
498
+ else console.error(`✗ ${adapterId} launch failed: ${result.error ?? 'unknown error'}`);
499
+ stream.complete(result.status === 'pass' ? 'pass' : 'fail', status);
500
+ return result.status === 'pass' ? 0 : 1;
501
+ },
502
+
503
+ // The web-dapp ships no action manifest: the host supplies its action set
504
+ // (`hooks.actions`).
505
+ actions: actions ?? {
506
+ manifestPath() {
507
+ throw new Error(
508
+ `${id} ships no action manifest: pass hooks.actions to createWebDappAdapter.`,
509
+ );
510
+ },
511
+ semantic: [],
512
+ cdpTarget: { transport: 'chrome-cdp', probePath: 'json/version' },
513
+ },
514
+
515
+ // The overlay and slot: install/verify/cleanup leaves.
516
+ harness: {
517
+ install: {
518
+ entry: webDappLeafPath('inject'),
519
+ fallback: webDappLeafPath('inject'),
520
+ node: true,
521
+ },
522
+ cleanup: {
523
+ entry: webDappLeafPath('cleanup'),
524
+ fallback: webDappLeafPath('cleanup'),
525
+ node: true,
526
+ },
527
+ verify: () => ({
528
+ command: process.execPath,
529
+ prefixArgs: [
530
+ webDappLeafPath('verify'),
531
+ ...(signerModule ? ['--signer-module', signerModule] : []),
532
+ ],
533
+ }),
534
+ restart: `${cli} launch`,
535
+ ...harness,
536
+ },
537
+
538
+ // An agentic-runtime.json whose ports all belong to the slot (never claimed here).
539
+ runtimeContext: {
540
+ forbiddenFields: ['metroPort', 'simulator', 'simulatorUdid', 'adbSerial', 'extensionId'],
541
+ resources: ({ existing, defaults, envPort }) => ({
542
+ cdpPort: envPort('RECIPE_CDP_PORT', 'CDP_PORT') ?? existing.cdpPort ?? defaults.cdpPort,
543
+ }),
544
+ },
545
+
546
+ readiness: {
547
+ // The slot's readiness (dev server, testnet, fixture, browser, the signer's
548
+ // own checks, CDP port) as doctor checks; the browser itself is optional
549
+ // because recipes start it with their launch node. Without a venue policy
550
+ // (web-dapp selected on its own) doctor reports that one required check.
551
+ async liveChecks(target) {
552
+ if (!process.env[POLICY_ENV]) {
553
+ return [
554
+ {
555
+ id: 'web-dapp-venue-policy',
556
+ status: 'fail',
557
+ required: true,
558
+ message: `${id} needs a venue policy: select an adapter that extends it (e.g. --adapter terminal with its recipe library on RECIPE_LIBRARY_PATH).`,
559
+ },
560
+ ];
561
+ }
562
+ const policy = webDappPolicy();
563
+ const report = await webDappReadiness({
564
+ policy,
565
+ signers: await resolveSigners(),
566
+ target,
567
+ appPort: webDappAppPort(),
568
+ cdpPort: webDappCdpPort(),
569
+ signer: process.env.TERMINAL_SIGNER || defaultSigner(signerModule),
570
+ account: process.env.TERMINAL_ACCOUNT || 'dev1',
571
+ probeBrowser: resolveBrowser,
572
+ });
573
+ return report.checks.map((item) => ({
574
+ id: `${policy.adapterId}-${item.id}`,
575
+ status: item.status === 'pass' ? 'pass' : 'fail',
576
+ required: item.required,
577
+ message: item.detail,
578
+ }));
579
+ },
580
+ // Screenshots come from the slot browser over CDP.
581
+ captureProviders: ['cdp'],
582
+ ...readiness,
583
+ },
584
+
585
+ // The wallet request log; the console capture is the host's (`hooks.diagnostics.console`).
586
+ diagnostics: {
587
+ requestLog: {
588
+ path: (projectRoot) => webDappRuntimeFile(projectRoot, 'wallet-requests.jsonl'),
589
+ findings: (lines) => walletRequestFindings(lines),
590
+ },
591
+ ...diagnostics,
592
+ },
593
+
594
+ ...rest,
595
+ };
596
+ }
@@ -0,0 +1,100 @@
1
+ #!/usr/bin/env node
2
+ // inject.mjs — `mm-harness install --adapter <web-dapp adapter>`: prepare the slot
3
+ // runtime directory and install the wallet fixture the web-dapp actions read.
4
+ //
5
+ // Inputs: --target <checkout> [--fixture <wallet-fixture.json>] [--force]
6
+ // env RECIPE_WALLET_FIXTURE (fixture source when --fixture is absent)
7
+ // Outputs: <target>/<harness root>/<adapter id>/install.json (overlay marker),
8
+ // <target>/<runtime>/<adapter id>/ (0700), <runtime>/wallet-fixture.json (0600);
9
+ // JSON summary on stdout (account names only). Exit 0 installed; 1 failure; 2 usage.
10
+ // Never touches: product source files; never prints key material.
11
+
12
+ import {
13
+ chmodSync,
14
+ copyFileSync,
15
+ existsSync,
16
+ mkdirSync,
17
+ readFileSync,
18
+ writeFileSync,
19
+ } from 'node:fs';
20
+ import path from 'node:path';
21
+ import { fileURLToPath } from 'node:url';
22
+
23
+ import { recipeHarnessPath, recipeRuntimeDir, walletFixturePath } from './lib/paths.mjs';
24
+ import { webDappPolicy, webDappRuntimeDir } from './lib/runtime.mjs';
25
+
26
+ const usage = 'Usage: inject.mjs --target <checkout> [--fixture <wallet-fixture.json>] [--force]';
27
+
28
+ export function installWebDappRuntime({ target, fixture, force = false }) {
29
+ const root = path.resolve(target);
30
+ const { adapterId, checkout } = webDappPolicy();
31
+ if (!checkout.matches(root))
32
+ throw new Error(`${root} is not a ${checkout.name} checkout (needs ${checkout.needs}).`);
33
+ mkdirSync(path.join(root, recipeRuntimeDir()), { recursive: true, mode: 0o700 });
34
+ mkdirSync(webDappRuntimeDir(root), { recursive: true, mode: 0o700 });
35
+ const destination = walletFixturePath(root);
36
+ let fixtureAction = 'kept';
37
+ if (fixture && (force || !existsSync(destination))) {
38
+ const parsed = JSON.parse(readFileSync(fixture, 'utf8'));
39
+ if (!Array.isArray(parsed.accounts) || parsed.accounts.length === 0)
40
+ throw new Error(`${fixture} has no accounts.`);
41
+ copyFileSync(fixture, destination);
42
+ chmodSync(destination, 0o600);
43
+ fixtureAction = 'installed';
44
+ } else if (!existsSync(destination)) {
45
+ fixtureAction = 'missing';
46
+ }
47
+ const accounts = existsSync(destination)
48
+ ? JSON.parse(readFileSync(destination, 'utf8'))
49
+ .accounts.map((account) => account?.name)
50
+ .filter(Boolean)
51
+ : [];
52
+ const marker = {
53
+ schemaVersion: 1,
54
+ adapter: adapterId,
55
+ installedAt: new Date().toISOString(),
56
+ fixture: fixtureAction,
57
+ };
58
+ // The overlay directory is the install marker run/call auto-ensure checks.
59
+ const overlay = recipeHarnessPath(root, adapterId);
60
+ mkdirSync(overlay, { recursive: true, mode: 0o700 });
61
+ writeFileSync(path.join(overlay, 'install.json'), `${JSON.stringify(marker, null, 2)}\n`, {
62
+ mode: 0o600,
63
+ });
64
+ return {
65
+ status: fixtureAction === 'missing' ? 'fail' : 'pass',
66
+ target: root,
67
+ fixture: fixtureAction,
68
+ fixturePath: destination,
69
+ accounts,
70
+ };
71
+ }
72
+
73
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
74
+ const argv = process.argv.slice(2);
75
+ if (argv.includes('--help') || argv.includes('-h')) {
76
+ process.stdout.write(`${usage}\n`);
77
+ process.exit(0);
78
+ }
79
+ const value = (flag) => {
80
+ const index = argv.indexOf(flag);
81
+ return index >= 0 ? argv[index + 1] : undefined;
82
+ };
83
+ try {
84
+ const result = installWebDappRuntime({
85
+ target: value('--target') ?? process.cwd(),
86
+ fixture: value('--fixture') ?? process.env.RECIPE_WALLET_FIXTURE,
87
+ force: argv.includes('--force'),
88
+ });
89
+ process.stdout.write(`${JSON.stringify(result)}\n`);
90
+ if (result.status !== 'pass') {
91
+ process.stderr.write(
92
+ 'No wallet fixture installed. Next: set RECIPE_WALLET_FIXTURE or pass --fixture <wallet-fixture.json>.\n',
93
+ );
94
+ process.exit(1);
95
+ }
96
+ } catch (error) {
97
+ process.stderr.write(`${error.message}\n`);
98
+ process.exit(1);
99
+ }
100
+ }