@deeeed/metamask-harness 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/CHANGELOG.md +37 -20
  2. package/adapters/core/inject.sh +1 -0
  3. package/adapters/extension/cleanup.mjs +1 -1
  4. package/adapters/extension/inject.mjs +2 -1
  5. package/adapters/extension/launch-browser.cjs +18 -4
  6. package/adapters/extension/live.sh +14 -2
  7. package/adapters/extension/sidepanel-toggle.sh +33 -12
  8. package/adapters/extension/wallet-fixture-state.cjs +38 -24
  9. package/adapters/manifest.json +13 -0
  10. package/adapters/mobile/inject.sh +1 -0
  11. package/adapters/mobile/stop-metro.sh +20 -0
  12. package/adapters/shared/cli-ux.sh +20 -2
  13. package/adapters/shared/ensure-runner-deps.sh +30 -0
  14. package/adapters/shared/reap-checkout-metros.sh +53 -0
  15. package/adapters/shared/recipe-harness-root.mjs +23 -0
  16. package/adapters/shared/resolve-farmslot-ports-core.mjs +15 -6
  17. package/bin/mm-harness +45 -4
  18. package/dist/adapters/extension/runtime.js +3 -1
  19. package/dist/adapters/mobile/provision.js +34 -1
  20. package/dist/adapters/slot-ports.js +3 -8
  21. package/dist/cli.js +32 -1463
  22. package/dist/commands/call.js +183 -0
  23. package/dist/commands/completion-candidates.js +58 -0
  24. package/dist/commands/doctor.js +101 -0
  25. package/dist/commands/ensure-ready.js +24 -0
  26. package/dist/commands/flows.js +62 -0
  27. package/dist/commands/launch/extension.js +40 -0
  28. package/dist/commands/{launch.js → launch/index.js} +15 -47
  29. package/dist/commands/launch/mobile.js +10 -0
  30. package/dist/commands/manifest.js +72 -0
  31. package/dist/commands/parse-args.js +189 -0
  32. package/dist/commands/provision.js +136 -0
  33. package/dist/commands/resolve-extension.js +23 -0
  34. package/dist/commands/run-engine.js +341 -0
  35. package/dist/commands/run.js +217 -0
  36. package/dist/commands/runtime-decision.js +58 -0
  37. package/dist/commands/runtime-health.js +25 -0
  38. package/dist/commands/runtime-launch.js +139 -0
  39. package/dist/commands/self-test.js +52 -0
  40. package/dist/commands/stop.js +52 -0
  41. package/dist/harness.js +8 -48
  42. package/dist/mm-harness-cli.js +13 -8
  43. package/docs/CLI-SPEC.md +1 -1
  44. package/docs/CODE-MAP.md +62 -0
  45. package/library/README.md +14 -0
  46. package/library/actions/extension/platform/cdp.mjs +1 -1
  47. package/library/actions/extension/wallet/ensure_unlocked.mjs +6 -0
  48. package/library/actions/mobile/wallet/ensure_unlocked.mjs +13 -1
  49. package/package.json +6 -7
  50. package/src/adapters/core/surface.ts +0 -71
  51. package/src/adapters/extension/ensure-ready.ts +0 -185
  52. package/src/adapters/extension/extension-id.ts +0 -107
  53. package/src/adapters/extension/runtime-decision.ts +0 -445
  54. package/src/adapters/extension/runtime.ts +0 -407
  55. package/src/adapters/extension/surface.ts +0 -88
  56. package/src/adapters/mobile/deps-markers.ts +0 -21
  57. package/src/adapters/mobile/prepare.ts +0 -246
  58. package/src/adapters/mobile/provision.ts +0 -594
  59. package/src/adapters/mobile/runtime-decision.ts +0 -466
  60. package/src/adapters/mobile/surface.ts +0 -71
  61. package/src/adapters/resolve-farmslot-ports.ts +0 -13
  62. package/src/adapters/slot-ports.ts +0 -158
  63. package/src/adapters/surface.ts +0 -117
  64. package/src/adapters.ts +0 -601
  65. package/src/cli-color.ts +0 -92
  66. package/src/cli-commands.ts +0 -250
  67. package/src/cli-version.ts +0 -141
  68. package/src/cli.ts +0 -2091
  69. package/src/commands/debug.ts +0 -65
  70. package/src/commands/fixtures.ts +0 -198
  71. package/src/commands/launch.ts +0 -470
  72. package/src/commands/logs.ts +0 -99
  73. package/src/commands/shared.ts +0 -235
  74. package/src/commands/update.ts +0 -316
  75. package/src/completions-cache.ts +0 -86
  76. package/src/doctor.ts +0 -215
  77. package/src/harness.ts +0 -797
  78. package/src/heal-bounds.ts +0 -198
  79. package/src/index.ts +0 -15
  80. package/src/leaf-invoke.ts +0 -28
  81. package/src/live-adapter-contract.ts +0 -274
  82. package/src/manifest.ts +0 -47
  83. package/src/mm-harness-cli.ts +0 -655
  84. package/src/paths.ts +0 -198
  85. package/src/progress.ts +0 -117
  86. package/src/recording-target.ts +0 -147
  87. package/src/run-recording.ts +0 -329
  88. package/src/runner.ts +0 -108
  89. package/src/types.ts +0 -57
@@ -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
- }