@deeeed/metamask-harness 0.3.9 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/CHANGELOG.md +45 -1
  2. package/dist/adapters/core/surface.js +53 -0
  3. package/dist/adapters/extension/ensure-ready.js +109 -0
  4. package/dist/adapters/extension/extension-id.js +62 -0
  5. package/dist/adapters/extension/runtime-decision.js +305 -0
  6. package/dist/adapters/extension/runtime.js +324 -0
  7. package/dist/adapters/extension/surface.js +69 -0
  8. package/dist/adapters/mobile/deps-markers.js +22 -0
  9. package/dist/adapters/mobile/prepare.js +146 -0
  10. package/dist/adapters/mobile/provision.js +465 -0
  11. package/dist/adapters/mobile/runtime-decision.js +315 -0
  12. package/dist/adapters/mobile/surface.js +54 -0
  13. package/dist/adapters/slot-ports.js +146 -0
  14. package/dist/adapters/surface.js +14 -0
  15. package/dist/adapters.js +485 -0
  16. package/dist/cli-color.js +79 -0
  17. package/dist/cli-commands.js +224 -0
  18. package/dist/cli-version.js +111 -0
  19. package/dist/cli.js +1571 -0
  20. package/dist/commands/debug.js +56 -0
  21. package/dist/commands/fixtures.js +153 -0
  22. package/dist/commands/launch.js +325 -0
  23. package/dist/commands/logs.js +73 -0
  24. package/dist/commands/shared.js +157 -0
  25. package/dist/commands/update.js +243 -0
  26. package/dist/completions-cache.js +53 -0
  27. package/dist/doctor.js +169 -0
  28. package/dist/harness.js +627 -0
  29. package/dist/heal-bounds.js +120 -0
  30. package/dist/index.js +25 -0
  31. package/dist/leaf-invoke.js +19 -0
  32. package/dist/live-adapter-contract.js +240 -0
  33. package/dist/manifest.js +37 -0
  34. package/dist/mm-harness-cli.js +521 -0
  35. package/dist/paths.js +179 -0
  36. package/dist/progress.js +94 -0
  37. package/dist/recording-target.js +133 -0
  38. package/dist/run-recording.js +271 -0
  39. package/dist/runner.js +88 -0
  40. package/dist/types.js +0 -0
  41. package/docs/ADAPTER-SURFACE.md +119 -0
  42. package/docs/CLI-SPEC.md +26 -3
  43. package/docs/UX-PRINCIPLES.md +3 -0
  44. package/package.json +10 -2
  45. package/src/adapters/core/surface.ts +71 -0
  46. package/src/adapters/extension/surface.ts +88 -0
  47. package/src/adapters/mobile/provision.ts +594 -0
  48. package/src/adapters/mobile/surface.ts +71 -0
  49. package/src/adapters/slot-ports.ts +165 -0
  50. package/src/adapters/surface.ts +117 -0
  51. package/src/cli-commands.ts +1 -1
  52. package/src/cli.ts +239 -49
  53. package/src/commands/debug.ts +3 -1
  54. package/src/commands/fixtures.ts +13 -8
  55. package/src/commands/launch.ts +7 -156
  56. package/src/commands/logs.ts +29 -13
  57. package/src/harness.ts +140 -3
  58. package/src/mm-harness-cli.ts +71 -18
@@ -0,0 +1,594 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import crypto from 'node:crypto';
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+
7
+ import { recipeRuntimeDir } from '../../paths.ts';
8
+
9
+ export interface RunwayProvisionOptions {
10
+ json?: boolean;
11
+ platform?: string;
12
+ branch?: string;
13
+ defaultBranch?: string;
14
+ run?: string;
15
+ cacheRoot?: string;
16
+ simulator?: string;
17
+ runtime?: string;
18
+ deviceType?: string;
19
+ slot?: string;
20
+ watcherPort?: string;
21
+ runtimeDir?: string;
22
+ force?: boolean;
23
+ resolveOnly?: boolean;
24
+ rerunCommand: string;
25
+ }
26
+
27
+ export interface RunwayProvisionResult {
28
+ schemaVersion: 1;
29
+ command: 'provision';
30
+ adapter: 'mobile';
31
+ target: string;
32
+ platform: string;
33
+ status: 'pass' | 'fail';
34
+ exitCode: number;
35
+ slot?: SlotContext;
36
+ artifact?: RunwayArtifact | ResolvedRunwayArtifact;
37
+ cache?: { status: 'hit' | 'miss' | 'redownload'; path: string; root: string };
38
+ simulator?: { name: string; udid?: string; created: boolean; runtime?: string; deviceType?: string };
39
+ resolveOnly?: boolean;
40
+ installed?: boolean;
41
+ skipped?: boolean;
42
+ baselinePath?: string;
43
+ error?: { code: string; message: string; userAction: string };
44
+ }
45
+
46
+ interface SlotContext {
47
+ path?: string;
48
+ slotId?: string;
49
+ platform?: string;
50
+ simulator?: string;
51
+ runtime?: string;
52
+ deviceType?: string;
53
+ watcherPort?: string;
54
+ gitBranch?: string;
55
+ defaultBranch?: string;
56
+ }
57
+
58
+ interface RunwayArtifact {
59
+ repo: string;
60
+ branch: string;
61
+ runId: string;
62
+ artifactName: string;
63
+ revision: string;
64
+ appPath: string;
65
+ sizeBytes: number;
66
+ sha256: string;
67
+ }
68
+
69
+ interface ResolvedRunwayArtifact {
70
+ repo: string;
71
+ branch: string;
72
+ runId: string;
73
+ artifactName: string;
74
+ revision: string;
75
+ archiveSizeBytes?: number;
76
+ archiveDownloadUrl?: string;
77
+ createdAt?: string;
78
+ updatedAt?: string;
79
+ }
80
+
81
+ interface CacheMeta {
82
+ artifactName: string;
83
+ runId: string;
84
+ sizeBytes: number;
85
+ sha256: string;
86
+ }
87
+
88
+ interface GitHubArtifact {
89
+ name?: string;
90
+ expired?: boolean;
91
+ size_in_bytes?: number;
92
+ archive_download_url?: string;
93
+ created_at?: string;
94
+ updated_at?: string;
95
+ }
96
+
97
+ const RUNWAY_IOS_METADATA = {
98
+ artifactName: 'ios-app-main-dev-expo',
99
+ workflow: 'expo-dev-build.yml',
100
+ bundleId: 'io.metamask.MetaMask',
101
+ appDirName: 'MetaMask.app',
102
+ fallbackRepo: 'MetaMask/metamask-mobile',
103
+ } as const;
104
+
105
+ export async function provisionRunwayMobile(target: string, options: RunwayProvisionOptions): Promise<RunwayProvisionResult> {
106
+ const resolvedTarget = path.resolve(target);
107
+ const platform = options.platform ?? 'ios';
108
+ const slot = readSlotContext(resolvedTarget, options.runtimeDir);
109
+ if (options.slot) slot.slotId = options.slot;
110
+ if (options.watcherPort) slot.watcherPort = options.watcherPort;
111
+ const command = options.rerunCommand;
112
+ if (platform !== 'ios') {
113
+ return fail(resolvedTarget, platform, 'UNSUPPORTED_PLATFORM', 'runway provisioning currently installs the iOS .app artifact only.', command, slot);
114
+ }
115
+ const simulator = options.simulator ?? slot.simulator ?? process.env.IOS_SIMULATOR;
116
+ if (!simulator && !options.resolveOnly) {
117
+ return fail(resolvedTarget, platform, 'SIMULATOR_MISSING', 'no simulator was resolved from agentic-runtime.json, --simulator, or IOS_SIMULATOR.', command, slot);
118
+ }
119
+
120
+ const runtime = options.runtime ?? slot.runtime ?? process.env.IOS_RUNTIME;
121
+ const deviceType = options.deviceType ?? slot.deviceType ?? process.env.IOS_DEVICE_TYPE;
122
+ const repo = githubRepo(resolvedTarget);
123
+ const defaultBranch = options.defaultBranch ?? slot.defaultBranch ?? 'main';
124
+ const branch = options.branch ?? slot.gitBranch ?? gitBranch(resolvedTarget) ?? defaultBranch;
125
+
126
+ try {
127
+ if (options.resolveOnly) {
128
+ log(options, `runway: resolving Runway artifact for ${branch} (default ${defaultBranch})`);
129
+ const resolved = resolveArtifactRun(repo, branch, defaultBranch, options.run);
130
+ return {
131
+ schemaVersion: 1,
132
+ command: 'provision',
133
+ adapter: 'mobile',
134
+ target: resolvedTarget,
135
+ platform,
136
+ status: 'pass',
137
+ exitCode: 0,
138
+ slot,
139
+ resolveOnly: true,
140
+ artifact: resolved,
141
+ installed: false,
142
+ skipped: true,
143
+ };
144
+ }
145
+
146
+ const sim = ensureSimulator(simulator, runtime, deviceType);
147
+ if (!options.force && appInstalled(sim.udid ?? sim.name)) {
148
+ const baselinePath = writeRunwayBaseline(resolvedTarget, slot, platform, undefined, undefined, sim, true, options.runtimeDir);
149
+ return {
150
+ schemaVersion: 1,
151
+ command: 'provision',
152
+ adapter: 'mobile',
153
+ target: resolvedTarget,
154
+ platform,
155
+ status: 'pass',
156
+ exitCode: 0,
157
+ slot,
158
+ simulator: sim,
159
+ skipped: true,
160
+ installed: false,
161
+ baselinePath,
162
+ };
163
+ }
164
+
165
+ log(options, `runway: resolving Runway artifact for ${branch} (default ${defaultBranch})`);
166
+ const resolved = resolveArtifactRun(repo, branch, defaultBranch, options.run);
167
+ const cacheRoot = options.cacheRoot ?? defaultRunwayCacheRoot();
168
+ const cache = ensureCachedArtifact(repo, resolved.branch, resolved.runId, cacheRoot, options);
169
+
170
+ log(options, `runway: installing ${cache.artifact.appPath} on ${sim.name}`);
171
+ execFileSync('xcrun', ['simctl', 'install', sim.udid ?? sim.name, cache.artifact.appPath], { stdio: ['ignore', 'ignore', 'pipe'] });
172
+ const baselinePath = writeRunwayBaseline(resolvedTarget, slot, platform, resolved, cache.artifact, sim, false, options.runtimeDir);
173
+ return {
174
+ schemaVersion: 1,
175
+ command: 'provision',
176
+ adapter: 'mobile',
177
+ target: resolvedTarget,
178
+ platform,
179
+ status: 'pass',
180
+ exitCode: 0,
181
+ slot,
182
+ artifact: cache.artifact,
183
+ cache: { status: cache.status, path: cache.artifact.appPath, root: cacheRoot },
184
+ simulator: sim,
185
+ installed: true,
186
+ skipped: false,
187
+ baselinePath,
188
+ };
189
+ } catch (error) {
190
+ return fail(resolvedTarget, platform, 'PROVISION_FAILED', errorMessage(error), command, slot);
191
+ }
192
+ }
193
+
194
+ export function runwayBaselinePath(target: string, runtimeDir?: string): string {
195
+ return path.join(target, resolveRuntimeDir(runtimeDir), 'runway-provision.json');
196
+ }
197
+
198
+ export function hasRunwayProvisionBaseline(target: string): boolean {
199
+ try {
200
+ const data = JSON.parse(fs.readFileSync(runwayBaselinePath(target), 'utf8')) as Record<string, unknown>;
201
+ if (data.appInstalled !== true) return false;
202
+ const simulators = baselineSimulatorCandidates(data, readSlotContext(path.resolve(target)));
203
+ return simulators.some((simulator) => appInstalled(simulator));
204
+ } catch {
205
+ return false;
206
+ }
207
+ }
208
+
209
+ function fail(target: string, platform: string, code: string, message: string, rerunCommand: string, slot?: SlotContext): RunwayProvisionResult {
210
+ return {
211
+ schemaVersion: 1,
212
+ command: 'provision',
213
+ adapter: 'mobile',
214
+ target,
215
+ platform,
216
+ status: 'fail',
217
+ exitCode: 1,
218
+ slot,
219
+ error: { code, message, userAction: rerunCommand },
220
+ };
221
+ }
222
+
223
+ function readSlotContext(target: string, runtimeDir?: string): SlotContext {
224
+ const candidates = [
225
+ process.env.RECIPE_RUNTIME_CONTEXT,
226
+ runtimeContextPath(target, runtimeDir),
227
+ path.join(target, recipeRuntimeDir(), 'agentic-runtime.json'),
228
+ path.join(target, 'temp/recipe/runtime/agentic-runtime.json'),
229
+ path.join(target, 'temp/agentic/recipe-harness/agentic-runtime.json'),
230
+ ].filter((value): value is string => Boolean(value));
231
+ for (const candidate of [...new Set(candidates)]) {
232
+ try {
233
+ const data = JSON.parse(fs.readFileSync(candidate, 'utf8')) as Record<string, unknown>;
234
+ return {
235
+ path: candidate,
236
+ slotId: stringField(data, 'slotId'),
237
+ platform: stringField(data, 'platform'),
238
+ simulator: stringField(data, 'simulator') ?? stringField(data, 'iosSimulator'),
239
+ runtime: stringField(data, 'runtime') ?? stringField(data, 'iosRuntime'),
240
+ deviceType: stringField(data, 'deviceType') ?? stringField(data, 'iosDeviceType'),
241
+ watcherPort: stringField(data, 'watcherPort') ?? stringField(data, 'metroPort') ?? stringField(data, 'devServerPort'),
242
+ gitBranch: stringField(data, 'gitBranch') ?? stringField(data, 'prepareRef'),
243
+ defaultBranch: stringField(data, 'defaultBranch') ?? stringField(data, 'prepareDefaultRef'),
244
+ };
245
+ } catch {
246
+ // Try the next context candidate.
247
+ }
248
+ }
249
+ return {};
250
+ }
251
+
252
+
253
+ function runtimeContextPath(target: string, runtimeDir?: string): string | undefined {
254
+ if (!runtimeDir) return undefined;
255
+ try {
256
+ return path.join(target, resolveRuntimeDir(runtimeDir), 'agentic-runtime.json');
257
+ } catch {
258
+ return undefined;
259
+ }
260
+ }
261
+
262
+ function stringField(data: Record<string, unknown>, key: string): string | undefined {
263
+ const value = data[key];
264
+ return typeof value === 'string' && value ? value : undefined;
265
+ }
266
+
267
+ function githubRepo(target: string): string {
268
+ try {
269
+ const remote = execFileSync('git', ['-C', target, 'config', '--get', 'remote.origin.url'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
270
+ const match = /github\.com[:/]([^/]+\/[^/.]+)(?:\.git)?$/u.exec(remote);
271
+ if (match) return match[1];
272
+ } catch {
273
+ // Bare checkouts without git metadata use the canonical upstream repo.
274
+ }
275
+ return RUNWAY_IOS_METADATA.fallbackRepo;
276
+ }
277
+
278
+ function gitBranch(target: string): string | undefined {
279
+ try {
280
+ return execFileSync('git', ['-C', target, 'branch', '--show-current'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() || undefined;
281
+ } catch {
282
+ return undefined;
283
+ }
284
+ }
285
+
286
+ function resolveArtifactRun(repo: string, branch: string, defaultBranch: string, runOverride?: string): ResolvedRunwayArtifact {
287
+ if (runOverride) {
288
+ assertValidRunId(runOverride);
289
+ const artifact = findRunArtifact(repo, runOverride, RUNWAY_IOS_METADATA.artifactName);
290
+ if (!artifact) throw new Error(`Runway run ${runOverride} does not contain ${RUNWAY_IOS_METADATA.artifactName}.`);
291
+ const revision = runRevision(repo, runOverride) ?? runOverride;
292
+ return resolvedArtifact(repo, branch, runOverride, revision, artifact);
293
+ }
294
+ const tried = [...new Set([branch, defaultBranch])];
295
+ for (const candidate of tried) {
296
+ const run = latestRunWithArtifact(repo, candidate, RUNWAY_IOS_METADATA.artifactName);
297
+ if (run) return resolvedArtifact(repo, candidate, run.runId, run.revision, run.artifact);
298
+ }
299
+ throw new Error(`no Runway artifact found for ${RUNWAY_IOS_METADATA.artifactName} on ${tried.join(', ')}.`);
300
+ }
301
+
302
+ function latestRunWithArtifact(repo: string, branch: string, artifactName: string): { runId: string; revision: string; artifact: GitHubArtifact } | undefined {
303
+ let runs: Array<{ databaseId?: number; headSha?: string }>;
304
+ try {
305
+ runs = execJson<Array<{ databaseId?: number; headSha?: string }>>('gh', [
306
+ 'run', 'list', '--repo', repo, `--workflow=${RUNWAY_IOS_METADATA.workflow}`, `--branch=${branch}`, '--status=success', '--limit=30', '--json', 'databaseId,headSha',
307
+ ]);
308
+ } catch {
309
+ return undefined;
310
+ }
311
+ for (const run of runs) {
312
+ const runId = run.databaseId ? String(run.databaseId) : '';
313
+ if (!runId) continue;
314
+ const artifact = findRunArtifact(repo, runId, artifactName);
315
+ if (artifact) return { runId, revision: run.headSha || runId, artifact };
316
+ }
317
+ return undefined;
318
+ }
319
+
320
+ function assertValidRunId(runId: string): void {
321
+ if (!/^\d+$/u.test(runId)) throw new Error(`invalid Runway run id: ${runId}.`);
322
+ }
323
+
324
+ function findRunArtifact(repo: string, runId: string, artifactName: string): GitHubArtifact | undefined {
325
+ assertValidRunId(runId);
326
+ try {
327
+ const data = execJson<{ artifacts?: GitHubArtifact[] }>('gh', [
328
+ 'api', `repos/${repo}/actions/runs/${runId}/artifacts`, '--paginate',
329
+ ]);
330
+ return data.artifacts?.find((artifact) => artifact.expired === false && artifact.name === artifactName);
331
+ } catch {
332
+ return undefined;
333
+ }
334
+ }
335
+
336
+ function runRevision(repo: string, runId: string): string | undefined {
337
+ try {
338
+ const data = execJson<{ headSha?: string }>('gh', ['run', 'view', runId, '--repo', repo, '--json', 'headSha']);
339
+ return data.headSha;
340
+ } catch {
341
+ return undefined;
342
+ }
343
+ }
344
+
345
+ function resolvedArtifact(repo: string, branch: string, runId: string, revision: string, artifact: GitHubArtifact): ResolvedRunwayArtifact {
346
+ return {
347
+ repo,
348
+ branch,
349
+ runId,
350
+ artifactName: RUNWAY_IOS_METADATA.artifactName,
351
+ revision,
352
+ archiveSizeBytes: artifact.size_in_bytes,
353
+ archiveDownloadUrl: artifact.archive_download_url,
354
+ createdAt: artifact.created_at,
355
+ updatedAt: artifact.updated_at,
356
+ };
357
+ }
358
+
359
+ function ensureCachedArtifact(
360
+ repo: string,
361
+ branch: string,
362
+ runId: string,
363
+ cacheRoot: string,
364
+ options: RunwayProvisionOptions,
365
+ ): { status: 'hit' | 'miss' | 'redownload'; artifact: RunwayArtifact } {
366
+ const dir = path.join(cacheRoot, runId);
367
+ const appPath = path.join(dir, RUNWAY_IOS_METADATA.appDirName);
368
+ const metaPath = path.join(dir, 'metadata.json');
369
+ const valid = validateCache(appPath, metaPath, RUNWAY_IOS_METADATA.artifactName, runId);
370
+ if (valid) {
371
+ return { status: 'hit', artifact: { repo, branch, runId, artifactName: RUNWAY_IOS_METADATA.artifactName, revision: runId, appPath, sizeBytes: valid.sizeBytes, sha256: valid.sha256 } };
372
+ }
373
+ const redownload = fs.existsSync(dir);
374
+ if (redownload) {
375
+ const corruptDir = path.join(cacheRoot, `${runId}.corrupt-${Date.now()}`);
376
+ fs.renameSync(dir, corruptDir);
377
+ }
378
+ fs.mkdirSync(dir, { recursive: true });
379
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'mm-runway-artifact-'));
380
+ try {
381
+ log(options, `runway: cache ${redownload ? 'corrupt' : 'miss'} for run ${runId}; downloading once`);
382
+ execFileSync('gh', ['run', 'download', runId, '--repo', repo, '--name', RUNWAY_IOS_METADATA.artifactName, '--dir', tmp], { stdio: ['ignore', 'ignore', 'pipe'] });
383
+ const downloaded = findAppOrExtractArchive(tmp);
384
+ if (!downloaded) throw new Error('downloaded artifact did not contain an iOS .app bundle.');
385
+ movePath(downloaded, appPath);
386
+ } finally {
387
+ fs.rmSync(tmp, { recursive: true, force: true });
388
+ }
389
+ const digest = hashPath(appPath);
390
+ const meta: CacheMeta = { artifactName: RUNWAY_IOS_METADATA.artifactName, runId, sizeBytes: digest.sizeBytes, sha256: digest.sha256 };
391
+ fs.writeFileSync(metaPath, `${JSON.stringify(meta, null, 2)}\n`);
392
+ return { status: redownload ? 'redownload' : 'miss', artifact: { repo, branch, runId, artifactName: RUNWAY_IOS_METADATA.artifactName, revision: runId, appPath, ...digest } };
393
+ }
394
+
395
+ function validateCache(appPath: string, metaPath: string, artifactName: string, runId: string): { sizeBytes: number; sha256: string } | null {
396
+ if (!fs.existsSync(appPath) || !fs.existsSync(metaPath)) return null;
397
+ try {
398
+ const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8')) as CacheMeta;
399
+ if (meta.artifactName !== artifactName || meta.runId !== runId) return null;
400
+ const digest = hashPath(appPath);
401
+ return digest.sizeBytes === meta.sizeBytes && digest.sha256 === meta.sha256 ? digest : null;
402
+ } catch {
403
+ return null;
404
+ }
405
+ }
406
+
407
+ function findApp(root: string): string | undefined {
408
+ const entries = fs.readdirSync(root, { withFileTypes: true });
409
+ let app: string | undefined;
410
+ for (const entry of entries) {
411
+ const full = path.join(root, entry.name);
412
+ if (entry.isDirectory() && entry.name === RUNWAY_IOS_METADATA.appDirName) return full;
413
+ if (entry.isDirectory() && entry.name.endsWith('.app')) {
414
+ app ??= full;
415
+ continue;
416
+ }
417
+ if (entry.isDirectory()) {
418
+ const nested = findApp(full);
419
+ if (nested) return nested;
420
+ }
421
+ }
422
+ return app;
423
+ }
424
+
425
+ function findAppOrExtractArchive(root: string): string | undefined {
426
+ return findAppOrExtractArchiveInner(root, new Set(), 0);
427
+ }
428
+
429
+ function findAppOrExtractArchiveInner(root: string, extracted: Set<string>, depth: number): string | undefined {
430
+ const app = findApp(root);
431
+ if (app) return app;
432
+ if (depth >= 3) return undefined;
433
+ for (const archive of findZipArchives(root)) {
434
+ const realArchive = fs.realpathSync(archive);
435
+ if (extracted.has(realArchive)) continue;
436
+ extracted.add(realArchive);
437
+ const extractDir = fs.mkdtempSync(path.join(root, '.mm-runway-unzip-'));
438
+ execFileSync('unzip', ['-q', archive, '-d', extractDir], { stdio: ['ignore', 'ignore', 'pipe'] });
439
+ const nested = findAppOrExtractArchiveInner(extractDir, extracted, depth + 1);
440
+ if (nested) return nested;
441
+ }
442
+ return undefined;
443
+ }
444
+
445
+ function findZipArchives(root: string): string[] {
446
+ const archives: string[] = [];
447
+ const visit = (dir: string): void => {
448
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
449
+ for (const entry of entries) {
450
+ const full = path.join(dir, entry.name);
451
+ if (entry.isDirectory()) {
452
+ if (!entry.name.endsWith('.app')) visit(full);
453
+ continue;
454
+ }
455
+ if (entry.isFile() && entry.name.toLowerCase().endsWith('.zip')) archives.push(full);
456
+ }
457
+ };
458
+ visit(root);
459
+ return archives.sort();
460
+ }
461
+
462
+ function movePath(source: string, destination: string): void {
463
+ try {
464
+ fs.renameSync(source, destination);
465
+ } catch (error) {
466
+ if (error instanceof Error && 'code' in error && error.code === 'EXDEV') {
467
+ fs.cpSync(source, destination, { recursive: true });
468
+ fs.rmSync(source, { recursive: true, force: true });
469
+ return;
470
+ }
471
+ throw error;
472
+ }
473
+ }
474
+
475
+ function hashPath(root: string): { sizeBytes: number; sha256: string } {
476
+ const hash = crypto.createHash('sha256');
477
+ let sizeBytes = 0;
478
+ const visitDirectory = (dir: string, relative = ''): void => {
479
+ const entries = fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
480
+ for (const entry of entries) {
481
+ const full = path.join(dir, entry.name);
482
+ const rel = path.join(relative, entry.name);
483
+ if (entry.isDirectory()) {
484
+ visitDirectory(full, rel);
485
+ continue;
486
+ }
487
+ if (!entry.isFile()) continue;
488
+ hash.update(rel);
489
+ const data = fs.readFileSync(full);
490
+ sizeBytes += data.length;
491
+ hash.update(data);
492
+ }
493
+ };
494
+ visitDirectory(root);
495
+ return { sizeBytes, sha256: hash.digest('hex') };
496
+ }
497
+
498
+ function ensureSimulator(name: string, runtime?: string, deviceType?: string): { name: string; udid?: string; created: boolean; runtime?: string; deviceType?: string } {
499
+ const existing = findSimulator(name);
500
+ if (existing) return { name, udid: existing, created: false, runtime, deviceType };
501
+ if (!runtime || !deviceType) {
502
+ throw new Error(`simulator ${name} is missing and runtime/device type could not be resolved. Pass --runtime and --device-type, or write them to agentic-runtime.json.`);
503
+ }
504
+ const udid = execFileSync('xcrun', ['simctl', 'create', name, deviceType, runtime], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
505
+ return { name, udid: udid || name, created: true, runtime, deviceType };
506
+ }
507
+
508
+ function findSimulator(name: string): string | undefined {
509
+ const data = execJson<{ devices?: Record<string, Array<{ name?: string; udid?: string }>> }>('xcrun', ['simctl', 'list', 'devices', '--json']);
510
+ for (const devices of Object.values(data.devices ?? {})) {
511
+ const found = devices.find((device) => device.name === name || device.udid === name);
512
+ if (found?.udid) return found.udid;
513
+ }
514
+ return undefined;
515
+ }
516
+
517
+
518
+ function baselineSimulatorCandidates(data: Record<string, unknown>, current: SlotContext): string[] {
519
+ const candidates: string[] = [];
520
+ if (current.simulator) candidates.push(current.simulator);
521
+ const simulator = data.simulator;
522
+ if (simulator && typeof simulator === 'object' && !Array.isArray(simulator)) {
523
+ const record = simulator as Record<string, unknown>;
524
+ for (const key of ['udid', 'name'] as const) {
525
+ const value = record[key];
526
+ if (typeof value === 'string' && value) candidates.push(value);
527
+ }
528
+ }
529
+ return [...new Set(candidates)];
530
+ }
531
+
532
+ function appInstalled(device: string): boolean {
533
+ try {
534
+ execFileSync('xcrun', ['simctl', 'get_app_container', device, RUNWAY_IOS_METADATA.bundleId, 'app'], { stdio: ['ignore', 'ignore', 'ignore'] });
535
+ return true;
536
+ } catch {
537
+ return false;
538
+ }
539
+ }
540
+
541
+ function writeRunwayBaseline(
542
+ target: string,
543
+ slot: SlotContext,
544
+ platform: string,
545
+ resolved: { branch: string; runId: string; artifactName: string; revision: string } | undefined,
546
+ artifact: RunwayArtifact | undefined,
547
+ simulator: { name: string; udid?: string; created: boolean; runtime?: string; deviceType?: string },
548
+ alreadyInstalled: boolean,
549
+ runtimeDir?: string,
550
+ ): string {
551
+ const file = runwayBaselinePath(target, runtimeDir);
552
+ fs.mkdirSync(path.dirname(file), { recursive: true });
553
+ fs.writeFileSync(file, `${JSON.stringify({
554
+ schemaVersion: 1,
555
+ appInstalled: true,
556
+ deps: 'pending',
557
+ platform,
558
+ slotId: slot.slotId ?? null,
559
+ watcherPort: slot.watcherPort ?? null,
560
+ simulator,
561
+ artifact: resolved && artifact ? { ...resolved, path: artifact.appPath, sizeBytes: artifact.sizeBytes, sha256: artifact.sha256 } : null,
562
+ alreadyInstalled,
563
+ recordedAt: new Date().toISOString(),
564
+ }, null, 2)}\n`);
565
+ return file;
566
+ }
567
+
568
+
569
+ function resolveRuntimeDir(runtimeDir?: string): string {
570
+ if (runtimeDir === undefined) return recipeRuntimeDir();
571
+ if (!runtimeDir || path.isAbsolute(runtimeDir)) throw new Error(`--runtime-dir must be a non-empty relative path: ${runtimeDir}`);
572
+ if (!/^[A-Za-z0-9._/-]+$/u.test(runtimeDir)) throw new Error(`--runtime-dir contains unsupported characters: ${runtimeDir}`);
573
+ for (const part of runtimeDir.split('/')) {
574
+ if (!part || part === '.' || part === '..') throw new Error(`--runtime-dir contains unsafe path component: ${runtimeDir}`);
575
+ }
576
+ return runtimeDir;
577
+ }
578
+
579
+ function defaultRunwayCacheRoot(): string {
580
+ return path.join(process.env.XDG_CACHE_HOME ?? path.join(os.homedir(), '.cache'), 'metamask-harness', 'runway');
581
+ }
582
+
583
+ function execJson<T>(bin: string, args: string[]): T {
584
+ const out = execFileSync(bin, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
585
+ return JSON.parse(out) as T;
586
+ }
587
+
588
+ function errorMessage(error: unknown): string {
589
+ return error instanceof Error ? error.message : String(error);
590
+ }
591
+
592
+ function log(options: RunwayProvisionOptions, message: string): void {
593
+ if (!options.json) process.stderr.write(`${message}\n`);
594
+ }
@@ -0,0 +1,71 @@
1
+ // Mobile surface: delegates to the existing mobile readiness/port plumbing.
2
+ import { spawnSync } from 'node:child_process';
3
+ import path from 'node:path';
4
+
5
+ import { recipeRuntimePath, runnerDir } from '../../paths.ts';
6
+ import { resolveMobileSlotPorts } from '../slot-ports.ts';
7
+ import { mobileRuntimeStatus } from './prepare.ts';
8
+ import { hasRunwayProvisionBaseline, provisionRunwayMobile } from './provision.ts';
9
+ import type {
10
+ AdapterDevServerStop,
11
+ AdapterLogSource,
12
+ AdapterRuntimeStatus,
13
+ AdapterRunwayProvisionResult,
14
+ AdapterSurface,
15
+ } from '../surface.ts';
16
+
17
+ export const mobileSurface: AdapterSurface = {
18
+ adapter: 'mobile',
19
+ headless: false,
20
+
21
+ resolveSlotPorts(target: string): void {
22
+ resolveMobileSlotPorts(target);
23
+ },
24
+
25
+ async runtimeStatus(target: string): Promise<AdapterRuntimeStatus> {
26
+ const watcherPort = process.env.WATCHER_PORT ? parseInt(process.env.WATCHER_PORT, 10) : undefined;
27
+ const report = await mobileRuntimeStatus(target, { watcherPort });
28
+ const runwayProvisioned = hasRunwayProvisionBaseline(target);
29
+ const depsPending = runwayProvisioned && report.checks?.deps?.status !== 'current';
30
+ return {
31
+ decision: depsPending ? 'launch' : report.decision,
32
+ reasonCode: depsPending ? 'app-installed-deps-pending' : report.reasonCode,
33
+ reasons: depsPending
34
+ ? ['Runway app is installed; JavaScript dependencies are pending until dispatch-time launch.']
35
+ : report.reasons,
36
+ deps: runwayProvisioned && report.checks?.deps?.status !== 'current' ? 'pending' : report.checks?.deps?.status,
37
+ devServer: { label: 'metro', status: report.checks?.metro?.status ?? 'unprobed' },
38
+ };
39
+ },
40
+
41
+ runwayProvision: {
42
+ async run(target, options): Promise<AdapterRunwayProvisionResult> {
43
+ return provisionRunwayMobile(target, options) as Promise<AdapterRunwayProvisionResult>;
44
+ },
45
+ },
46
+
47
+ devServer: {
48
+ describe: () => 'Metro dev server',
49
+ stop(target: string): AdapterDevServerStop {
50
+ const leaf = path.join(runnerDir, 'adapters', 'mobile', 'stop-metro.sh');
51
+ const args = ['--target', target];
52
+ if (process.env.WATCHER_PORT) args.push('--port', process.env.WATCHER_PORT);
53
+ const result = spawnSync('bash', [leaf, ...args], { encoding: 'utf8' });
54
+ const status = result.status ?? 1;
55
+ const output = `${result.stdout ?? ''}${result.stderr ?? ''}`.trim();
56
+ const summary = status === 0
57
+ ? `stopped Metro dev server for ${target}`
58
+ : `failed to stop Metro for ${target}`;
59
+ return { kind: 'stopped', status, summary, output };
60
+ },
61
+ },
62
+
63
+ logSources(target: string): AdapterLogSource[] {
64
+ return [{ label: 'metro', path: recipeRuntimePath(target, 'metro.log') }];
65
+ },
66
+
67
+ hints: {
68
+ launch: 'mm-harness launch ios',
69
+ relaunch: 'mm-harness launch ios',
70
+ },
71
+ };