@deeeed/metamask-harness 0.5.0 → 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 (102) hide show
  1. package/CHANGELOG.md +51 -20
  2. package/adapters/core/inject.sh +1 -0
  3. package/adapters/extension/cleanup.mjs +1 -1
  4. package/adapters/extension/inject.mjs +4 -1
  5. package/adapters/extension/launch-browser.cjs +18 -4
  6. package/adapters/extension/live.sh +14 -2
  7. package/adapters/extension/refresh-build.sh +2 -2
  8. package/adapters/extension/sidepanel-toggle.sh +33 -12
  9. package/adapters/extension/start-watch.sh +2 -2
  10. package/adapters/extension/wallet-fixture-state.cjs +38 -24
  11. package/adapters/manifest.json +32 -3
  12. package/adapters/mobile/inject.sh +1 -0
  13. package/adapters/mobile/start-metro.sh +1 -1
  14. package/adapters/mobile/stop-metro.sh +20 -0
  15. package/adapters/mobile/verify.sh +1 -1
  16. package/adapters/shared/activate-repo-node.sh +1 -1
  17. package/adapters/shared/cli-ux.sh +44 -25
  18. package/adapters/shared/ensure-runner-deps.sh +30 -0
  19. package/adapters/shared/log-tui.mjs +4 -4
  20. package/adapters/shared/open-debug.mjs +1 -1
  21. package/adapters/shared/reap-checkout-metros.sh +53 -0
  22. package/adapters/shared/recipe-harness-root.mjs +23 -0
  23. package/adapters/shared/resolve-farmslot-ports-core.mjs +205 -0
  24. package/adapters/shared/resolve-farmslot-ports.mjs +20 -0
  25. package/adapters/shared/resolve-farmslot-ports.sh +19 -126
  26. package/bin/mm-harness +45 -4
  27. package/dist/adapters/extension/runtime.js +3 -1
  28. package/dist/adapters/mobile/provision.js +34 -1
  29. package/dist/adapters/mobile/runtime-decision.js +3 -1
  30. package/dist/adapters/resolve-farmslot-ports.js +22 -0
  31. package/dist/adapters/slot-ports.js +18 -29
  32. package/dist/cli.js +32 -1463
  33. package/dist/commands/call.js +183 -0
  34. package/dist/commands/completion-candidates.js +58 -0
  35. package/dist/commands/doctor.js +101 -0
  36. package/dist/commands/ensure-ready.js +24 -0
  37. package/dist/commands/flows.js +62 -0
  38. package/dist/commands/launch/extension.js +40 -0
  39. package/dist/commands/{launch.js → launch/index.js} +15 -47
  40. package/dist/commands/launch/mobile.js +10 -0
  41. package/dist/commands/manifest.js +72 -0
  42. package/dist/commands/parse-args.js +189 -0
  43. package/dist/commands/provision.js +136 -0
  44. package/dist/commands/resolve-extension.js +23 -0
  45. package/dist/commands/run-engine.js +341 -0
  46. package/dist/commands/run.js +217 -0
  47. package/dist/commands/runtime-decision.js +58 -0
  48. package/dist/commands/runtime-health.js +25 -0
  49. package/dist/commands/runtime-launch.js +139 -0
  50. package/dist/commands/self-test.js +52 -0
  51. package/dist/commands/stop.js +52 -0
  52. package/dist/harness.js +8 -48
  53. package/dist/mm-harness-cli.js +13 -8
  54. package/docs/CLI-SPEC.md +1 -1
  55. package/docs/CODE-MAP.md +62 -0
  56. package/library/README.md +14 -0
  57. package/library/actions/core/perps/_controller.mjs +1 -1
  58. package/library/actions/extension/platform/cdp.mjs +2 -2
  59. package/library/actions/extension/wallet/ensure_unlocked.mjs +7 -1
  60. package/library/actions/harness-exports.mjs +27 -0
  61. package/library/actions/mobile/wallet/ensure_unlocked.mjs +14 -2
  62. package/library/actions/mobile/wallet/setup.mjs +1 -1
  63. package/package.json +6 -7
  64. package/src/adapters/core/surface.ts +0 -71
  65. package/src/adapters/extension/ensure-ready.ts +0 -185
  66. package/src/adapters/extension/extension-id.ts +0 -107
  67. package/src/adapters/extension/runtime-decision.ts +0 -445
  68. package/src/adapters/extension/runtime.ts +0 -407
  69. package/src/adapters/extension/surface.ts +0 -88
  70. package/src/adapters/mobile/deps-markers.ts +0 -21
  71. package/src/adapters/mobile/prepare.ts +0 -246
  72. package/src/adapters/mobile/provision.ts +0 -594
  73. package/src/adapters/mobile/runtime-decision.ts +0 -459
  74. package/src/adapters/mobile/surface.ts +0 -71
  75. package/src/adapters/slot-ports.ts +0 -165
  76. package/src/adapters/surface.ts +0 -117
  77. package/src/adapters.ts +0 -601
  78. package/src/cli-color.ts +0 -92
  79. package/src/cli-commands.ts +0 -250
  80. package/src/cli-version.ts +0 -141
  81. package/src/cli.ts +0 -2091
  82. package/src/commands/debug.ts +0 -65
  83. package/src/commands/fixtures.ts +0 -198
  84. package/src/commands/launch.ts +0 -470
  85. package/src/commands/logs.ts +0 -99
  86. package/src/commands/shared.ts +0 -235
  87. package/src/commands/update.ts +0 -316
  88. package/src/completions-cache.ts +0 -86
  89. package/src/doctor.ts +0 -215
  90. package/src/harness.ts +0 -797
  91. package/src/heal-bounds.ts +0 -198
  92. package/src/index.ts +0 -15
  93. package/src/leaf-invoke.ts +0 -28
  94. package/src/live-adapter-contract.ts +0 -274
  95. package/src/manifest.ts +0 -47
  96. package/src/mm-harness-cli.ts +0 -655
  97. package/src/paths.ts +0 -198
  98. package/src/progress.ts +0 -117
  99. package/src/recording-target.ts +0 -147
  100. package/src/run-recording.ts +0 -329
  101. package/src/runner.ts +0 -108
  102. package/src/types.ts +0 -57
@@ -1,594 +0,0 @@
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
- }