@deeeed/metamask-harness 0.4.0 → 0.5.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 (74) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/adapters/extension/inject.mjs +2 -0
  3. package/adapters/extension/refresh-build.sh +2 -2
  4. package/adapters/extension/start-watch.sh +2 -2
  5. package/adapters/manifest.json +19 -3
  6. package/adapters/mobile/start-metro.sh +1 -1
  7. package/adapters/mobile/verify.sh +1 -1
  8. package/adapters/shared/activate-repo-node.sh +1 -1
  9. package/adapters/shared/cli-ux.sh +24 -23
  10. package/adapters/shared/log-tui.mjs +4 -4
  11. package/adapters/shared/open-debug.mjs +1 -1
  12. package/adapters/shared/resolve-farmslot-ports-core.mjs +196 -0
  13. package/adapters/shared/resolve-farmslot-ports.mjs +20 -0
  14. package/adapters/shared/resolve-farmslot-ports.sh +19 -126
  15. package/dist/adapters/core/surface.js +53 -0
  16. package/dist/adapters/extension/ensure-ready.js +109 -0
  17. package/dist/adapters/extension/extension-id.js +62 -0
  18. package/dist/adapters/extension/runtime-decision.js +305 -0
  19. package/dist/adapters/extension/runtime.js +324 -0
  20. package/dist/adapters/extension/surface.js +69 -0
  21. package/dist/adapters/mobile/deps-markers.js +22 -0
  22. package/dist/adapters/mobile/prepare.js +146 -0
  23. package/dist/adapters/mobile/provision.js +465 -0
  24. package/dist/adapters/mobile/runtime-decision.js +317 -0
  25. package/dist/adapters/mobile/surface.js +54 -0
  26. package/dist/adapters/resolve-farmslot-ports.js +22 -0
  27. package/dist/adapters/slot-ports.js +140 -0
  28. package/dist/adapters/surface.js +14 -0
  29. package/dist/adapters.js +485 -0
  30. package/dist/cli-color.js +79 -0
  31. package/dist/cli-commands.js +224 -0
  32. package/dist/cli-version.js +111 -0
  33. package/dist/cli.js +1571 -0
  34. package/dist/commands/debug.js +56 -0
  35. package/dist/commands/fixtures.js +153 -0
  36. package/dist/commands/launch.js +325 -0
  37. package/dist/commands/logs.js +73 -0
  38. package/dist/commands/shared.js +157 -0
  39. package/dist/commands/update.js +243 -0
  40. package/dist/completions-cache.js +53 -0
  41. package/dist/doctor.js +169 -0
  42. package/dist/harness.js +627 -0
  43. package/dist/heal-bounds.js +120 -0
  44. package/dist/index.js +25 -0
  45. package/dist/leaf-invoke.js +19 -0
  46. package/dist/live-adapter-contract.js +240 -0
  47. package/dist/manifest.js +37 -0
  48. package/dist/mm-harness-cli.js +521 -0
  49. package/dist/paths.js +179 -0
  50. package/dist/progress.js +94 -0
  51. package/dist/recording-target.js +133 -0
  52. package/dist/run-recording.js +271 -0
  53. package/dist/runner.js +88 -0
  54. package/dist/types.js +0 -0
  55. package/docs/CLI-SPEC.md +26 -3
  56. package/library/actions/core/perps/_controller.mjs +1 -1
  57. package/library/actions/extension/platform/cdp.mjs +1 -1
  58. package/library/actions/extension/wallet/ensure_unlocked.mjs +1 -1
  59. package/library/actions/harness-exports.mjs +27 -0
  60. package/library/actions/mobile/wallet/ensure_unlocked.mjs +1 -1
  61. package/library/actions/mobile/wallet/setup.mjs +1 -1
  62. package/package.json +5 -1
  63. package/src/adapters/core/surface.ts +15 -0
  64. package/src/adapters/extension/surface.ts +20 -3
  65. package/src/adapters/mobile/provision.ts +594 -0
  66. package/src/adapters/mobile/runtime-decision.ts +8 -1
  67. package/src/adapters/mobile/surface.ts +16 -4
  68. package/src/adapters/resolve-farmslot-ports.ts +13 -0
  69. package/src/adapters/slot-ports.ts +18 -25
  70. package/src/adapters/surface.ts +35 -0
  71. package/src/cli-commands.ts +1 -1
  72. package/src/cli.ts +149 -6
  73. package/src/harness.ts +140 -3
  74. package/src/mm-harness-cli.ts +52 -5
@@ -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
+ }
@@ -201,6 +201,13 @@ export async function decideMobileReadiness(
201
201
  // mid-launch is inverted authority. Surface a teaching block, not an implicit
202
202
  // setup, so a runway preflight never re-does the orchestrator's deps work.
203
203
  if (fast && report.decision === 'install') {
204
+ // A checkout without node_modules cannot even run yarn scripts (yarn exits
205
+ // with "Couldn't find the node_modules state file"), so the taught command
206
+ // must lead with the install when that is the caller's actual state.
207
+ const hasNodeModules = fs.existsSync(path.join(resolved, 'node_modules'));
208
+ const standalone = hasNodeModules
209
+ ? '`yarn setup:expo --no-build-ios --no-build-android`'
210
+ : '`yarn install --immutable && yarn setup:expo --no-build-ios --no-build-android`';
204
211
  return {
205
212
  ...report,
206
213
  decision: 'blocked',
@@ -210,7 +217,7 @@ export async function decideMobileReadiness(
210
217
  ...report.reasons,
211
218
  ],
212
219
  userAction:
213
- 'run the slot deps/prepare phase; standalone: `yarn setup:expo --no-build-ios --no-build-android` in the checkout, or `mm-harness launch <platform> --build` to install and build',
220
+ `run the slot deps/prepare phase; standalone: ${standalone} in the checkout, then re-run \`mm-harness launch <platform>\``,
214
221
  actions: [],
215
222
  };
216
223
  }
@@ -5,10 +5,12 @@ import path from 'node:path';
5
5
  import { recipeRuntimePath, runnerDir } from '../../paths.ts';
6
6
  import { resolveMobileSlotPorts } from '../slot-ports.ts';
7
7
  import { mobileRuntimeStatus } from './prepare.ts';
8
+ import { hasRunwayProvisionBaseline, provisionRunwayMobile } from './provision.ts';
8
9
  import type {
9
10
  AdapterDevServerStop,
10
11
  AdapterLogSource,
11
12
  AdapterRuntimeStatus,
13
+ AdapterRunwayProvisionResult,
12
14
  AdapterSurface,
13
15
  } from '../surface.ts';
14
16
 
@@ -23,15 +25,25 @@ export const mobileSurface: AdapterSurface = {
23
25
  async runtimeStatus(target: string): Promise<AdapterRuntimeStatus> {
24
26
  const watcherPort = process.env.WATCHER_PORT ? parseInt(process.env.WATCHER_PORT, 10) : undefined;
25
27
  const report = await mobileRuntimeStatus(target, { watcherPort });
28
+ const runwayProvisioned = hasRunwayProvisionBaseline(target);
29
+ const depsPending = runwayProvisioned && report.checks?.deps?.status !== 'current';
26
30
  return {
27
- decision: report.decision,
28
- reasonCode: report.reasonCode,
29
- reasons: report.reasons,
30
- deps: report.checks?.deps?.status,
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,
31
37
  devServer: { label: 'metro', status: report.checks?.metro?.status ?? 'unprobed' },
32
38
  };
33
39
  },
34
40
 
41
+ runwayProvision: {
42
+ async run(target, options): Promise<AdapterRunwayProvisionResult> {
43
+ return provisionRunwayMobile(target, options) as Promise<AdapterRunwayProvisionResult>;
44
+ },
45
+ },
46
+
35
47
  devServer: {
36
48
  describe: () => 'Metro dev server',
37
49
  stop(target: string): AdapterDevServerStop {
@@ -0,0 +1,13 @@
1
+ // TypeScript surface for slot-port resolution — delegates to the self-contained core leaf.
2
+
3
+ export {
4
+ formatKvLines,
5
+ inferSlotSuffix,
6
+ realRepoPath,
7
+ resolveDefaultExtensionPorts,
8
+ resolveExtensionRuntimePorts,
9
+ resolveFarmslotPortsByRepo,
10
+ resolveMobileRuntimeContext,
11
+ resolveMobileRuntimePorts,
12
+ resolveMobileSlotDefaults,
13
+ } from '../../adapters/shared/resolve-farmslot-ports-core.mjs';