@ceraph/react-native-mcp 0.2.1 → 0.3.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 (124) hide show
  1. package/LICENSE +116 -15
  2. package/README.md +79 -77
  3. package/assets/default.png +0 -0
  4. package/dist/app-lifecycle.d.ts +50 -0
  5. package/dist/app-lifecycle.js +487 -0
  6. package/dist/camera-image-writer.d.ts +43 -0
  7. package/dist/camera-image-writer.js +280 -0
  8. package/dist/camera-registry-sync.d.ts +18 -0
  9. package/dist/camera-registry-sync.js +117 -0
  10. package/dist/cli.d.ts +0 -7
  11. package/dist/cli.js +41 -9
  12. package/dist/device-autonomy.d.ts +30 -0
  13. package/dist/device-autonomy.js +117 -0
  14. package/dist/error-parser.d.ts +6 -26
  15. package/dist/error-parser.js +4 -74
  16. package/dist/expo-manager.d.ts +2 -74
  17. package/dist/expo-manager.js +11 -125
  18. package/dist/index.d.ts +0 -7
  19. package/dist/index.js +1266 -56
  20. package/dist/init/ast-camera.d.ts +29 -0
  21. package/dist/init/ast-camera.js +267 -0
  22. package/dist/init/ast-layout.d.ts +15 -0
  23. package/dist/init/ast-layout.js +167 -0
  24. package/dist/init/claude-hook-constants.d.ts +9 -0
  25. package/dist/init/claude-hook-constants.js +91 -0
  26. package/dist/init/lan-ip.d.ts +11 -0
  27. package/dist/init/lan-ip.js +51 -0
  28. package/dist/init/monorepo.d.ts +13 -0
  29. package/dist/init/monorepo.js +185 -0
  30. package/dist/init/oauth.d.ts +52 -0
  31. package/dist/init/oauth.js +220 -0
  32. package/dist/init/package-manager.d.ts +11 -0
  33. package/dist/init/package-manager.js +60 -0
  34. package/dist/init/prompt.d.ts +12 -0
  35. package/dist/init/prompt.js +68 -0
  36. package/dist/init/shell-profile.d.ts +22 -0
  37. package/dist/init/shell-profile.js +85 -0
  38. package/dist/init/steps.d.ts +135 -0
  39. package/dist/init/steps.js +399 -0
  40. package/dist/init/url-scheme.d.ts +42 -0
  41. package/dist/init/url-scheme.js +187 -0
  42. package/dist/init/walkthrough.d.ts +76 -0
  43. package/dist/init/walkthrough.js +340 -0
  44. package/dist/init.d.ts +7 -7
  45. package/dist/init.js +280 -120
  46. package/dist/iproxy-manager.d.ts +32 -0
  47. package/dist/iproxy-manager.js +216 -0
  48. package/dist/mac-caffeinate.d.ts +10 -0
  49. package/dist/mac-caffeinate.js +56 -0
  50. package/dist/permission-interceptor.d.ts +29 -0
  51. package/dist/permission-interceptor.js +185 -0
  52. package/dist/prebuild-detector.d.ts +0 -30
  53. package/dist/prebuild-detector.js +1 -42
  54. package/dist/preflight.d.ts +34 -0
  55. package/dist/preflight.js +847 -0
  56. package/dist/screen.d.ts +132 -43
  57. package/dist/screen.js +668 -94
  58. package/dist/shim/boot.d.ts +41 -0
  59. package/dist/shim/boot.js +141 -0
  60. package/dist/shim/camera.d.ts +22 -0
  61. package/dist/shim/camera.js +62 -0
  62. package/dist/shim/config.d.ts +6 -0
  63. package/dist/shim/config.js +56 -0
  64. package/dist/shim/deep-link.d.ts +1 -0
  65. package/dist/shim/deep-link.js +25 -0
  66. package/dist/shim/dev-guard.d.ts +1 -0
  67. package/dist/shim/dev-guard.js +3 -0
  68. package/dist/shim/error-handler.d.ts +20 -0
  69. package/dist/shim/error-handler.js +66 -0
  70. package/dist/shim/fetch-interceptor.d.ts +13 -0
  71. package/dist/shim/fetch-interceptor.js +93 -0
  72. package/dist/shim/index.d.ts +6 -0
  73. package/dist/shim/index.js +6 -0
  74. package/dist/shim/keep-awake.d.ts +13 -0
  75. package/dist/shim/keep-awake.js +118 -0
  76. package/dist/shim/reload.d.ts +23 -0
  77. package/dist/shim/reload.js +76 -0
  78. package/dist/shim/signal-capture.d.ts +11 -0
  79. package/dist/shim/signal-capture.js +15 -0
  80. package/dist/shim/signal-transport.d.ts +17 -0
  81. package/dist/shim/signal-transport.js +43 -0
  82. package/dist/signal-listener.d.ts +27 -0
  83. package/dist/signal-listener.js +135 -0
  84. package/dist/simulator-boot.d.ts +52 -0
  85. package/dist/simulator-boot.js +227 -0
  86. package/dist/target.d.ts +48 -0
  87. package/dist/target.js +267 -0
  88. package/dist/uninstall/cli-runner.d.ts +32 -0
  89. package/dist/uninstall/cli-runner.js +223 -0
  90. package/dist/uninstall/footprint.d.ts +40 -0
  91. package/dist/uninstall/footprint.js +288 -0
  92. package/dist/uninstall/mcp-tools.d.ts +14 -0
  93. package/dist/uninstall/mcp-tools.js +175 -0
  94. package/dist/uninstall/revert-auth.d.ts +22 -0
  95. package/dist/uninstall/revert-auth.js +31 -0
  96. package/dist/uninstall/revert-boot.d.ts +24 -0
  97. package/dist/uninstall/revert-boot.js +242 -0
  98. package/dist/uninstall/revert-camera.d.ts +12 -0
  99. package/dist/uninstall/revert-camera.js +199 -0
  100. package/dist/uninstall/revert-ceraph-dir.d.ts +27 -0
  101. package/dist/uninstall/revert-ceraph-dir.js +38 -0
  102. package/dist/uninstall/revert-claude-hooks.d.ts +19 -0
  103. package/dist/uninstall/revert-claude-hooks.js +191 -0
  104. package/dist/uninstall/revert-gitignore.d.ts +17 -0
  105. package/dist/uninstall/revert-gitignore.js +43 -0
  106. package/dist/uninstall/revert-mcp-clients.d.ts +57 -0
  107. package/dist/uninstall/revert-mcp-clients.js +194 -0
  108. package/dist/uninstall/revert-package.d.ts +34 -0
  109. package/dist/uninstall/revert-package.js +98 -0
  110. package/dist/uninstall/revert-scheme.d.ts +36 -0
  111. package/dist/uninstall/revert-scheme.js +139 -0
  112. package/dist/uninstall/revert-signal-host-env.d.ts +31 -0
  113. package/dist/uninstall/revert-signal-host-env.js +61 -0
  114. package/dist/uninstall/walkthrough.d.ts +80 -0
  115. package/dist/uninstall/walkthrough.js +1244 -0
  116. package/dist/utils/atomic-write.d.ts +1 -0
  117. package/dist/utils/atomic-write.js +30 -0
  118. package/dist/wait-for-device.d.ts +68 -0
  119. package/dist/wait-for-device.js +368 -0
  120. package/dist/wda-manager.d.ts +38 -0
  121. package/dist/wda-manager.js +186 -0
  122. package/dist/wda-simulator.d.ts +28 -0
  123. package/dist/wda-simulator.js +257 -0
  124. package/package.json +38 -5
@@ -0,0 +1,847 @@
1
+ import { spawn } from "node:child_process";
2
+ import { readdir, readFile } from "node:fs/promises";
3
+ import { join, relative, sep } from "node:path";
4
+ import { syncCameraRegistry, relativeRegistryPath, } from "./camera-registry-sync.js";
5
+ function runCommand(cmd, args, timeoutMs = 4000) {
6
+ return new Promise((resolve) => {
7
+ let stdout = "";
8
+ let stderr = "";
9
+ let child;
10
+ try {
11
+ child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
12
+ }
13
+ catch (err) {
14
+ resolve({
15
+ code: 127,
16
+ stdout: "",
17
+ stderr: err instanceof Error ? err.message : String(err),
18
+ });
19
+ return;
20
+ }
21
+ const timer = setTimeout(() => {
22
+ try {
23
+ child.kill("SIGKILL");
24
+ }
25
+ catch {
26
+ }
27
+ }, timeoutMs);
28
+ child.stdout?.on("data", (d) => {
29
+ stdout += d.toString();
30
+ });
31
+ child.stderr?.on("data", (d) => {
32
+ stderr += d.toString();
33
+ });
34
+ let settled = false;
35
+ child.on("error", (err) => {
36
+ if (settled)
37
+ return;
38
+ settled = true;
39
+ clearTimeout(timer);
40
+ resolve({
41
+ code: 127,
42
+ stdout,
43
+ stderr: stderr + (err.message ?? String(err)),
44
+ });
45
+ });
46
+ child.on("exit", (code) => {
47
+ if (settled)
48
+ return;
49
+ settled = true;
50
+ clearTimeout(timer);
51
+ resolve({ code: code ?? 1, stdout, stderr });
52
+ });
53
+ });
54
+ }
55
+ async function safeCheck(name, severity, fn) {
56
+ try {
57
+ const result = await fn();
58
+ return { name, ...result };
59
+ }
60
+ catch (err) {
61
+ return {
62
+ name,
63
+ ok: false,
64
+ severity,
65
+ message: err instanceof Error ? err.message : String(err),
66
+ };
67
+ }
68
+ }
69
+ export async function pollUntil(fn, opts) {
70
+ const deadline = Date.now() + opts.timeoutMs;
71
+ try {
72
+ if (await fn())
73
+ return true;
74
+ }
75
+ catch {
76
+ }
77
+ while (Date.now() < deadline) {
78
+ await new Promise((r) => setTimeout(r, opts.intervalMs));
79
+ try {
80
+ if (await fn())
81
+ return true;
82
+ }
83
+ catch {
84
+ }
85
+ }
86
+ return false;
87
+ }
88
+ async function getLaptopSsid() {
89
+ const res = await runCommand("networksetup", ["-getairportnetwork", "en0"]);
90
+ if (res.code !== 0)
91
+ return null;
92
+ const match = res.stdout.match(/Current Wi-Fi Network:\s*(.+)$/m);
93
+ if (!match)
94
+ return null;
95
+ const ssid = match[1].trim();
96
+ if (!ssid || ssid.toLowerCase().includes("not associated"))
97
+ return null;
98
+ return ssid;
99
+ }
100
+ async function getLaptopIp() {
101
+ const res = await runCommand("ipconfig", ["getifaddr", "en0"]);
102
+ if (res.code !== 0)
103
+ return null;
104
+ const ip = res.stdout.trim();
105
+ return ip.length > 0 ? ip : null;
106
+ }
107
+ const SCAN_DIRECTORIES = ["app", "src", "screens"];
108
+ const SCAN_EXTENSIONS = new Set([".tsx", ".jsx"]);
109
+ const SKIP_DIRS = new Set([
110
+ "node_modules",
111
+ "dist",
112
+ "build",
113
+ ".git",
114
+ ".ceraph",
115
+ ".rn-mcp-cache",
116
+ ".expo",
117
+ ".next",
118
+ "ios",
119
+ "android",
120
+ ]);
121
+ async function listSourceFiles(projectDir) {
122
+ const out = [];
123
+ async function walk(dir) {
124
+ let entries;
125
+ try {
126
+ entries = await readdir(dir, { withFileTypes: true });
127
+ }
128
+ catch {
129
+ return;
130
+ }
131
+ for (const entry of entries) {
132
+ const abs = join(dir, entry.name);
133
+ if (entry.isDirectory()) {
134
+ if (SKIP_DIRS.has(entry.name))
135
+ continue;
136
+ await walk(abs);
137
+ continue;
138
+ }
139
+ if (!entry.isFile())
140
+ continue;
141
+ const dot = entry.name.lastIndexOf(".");
142
+ if (dot < 0)
143
+ continue;
144
+ const ext = entry.name.slice(dot).toLowerCase();
145
+ if (!SCAN_EXTENSIONS.has(ext))
146
+ continue;
147
+ let contents;
148
+ try {
149
+ contents = await readFile(abs, "utf-8");
150
+ }
151
+ catch {
152
+ continue;
153
+ }
154
+ const rel = relative(projectDir, abs).split(sep).join("/");
155
+ out.push({ absPath: abs, relPath: rel, contents });
156
+ }
157
+ }
158
+ for (const sub of SCAN_DIRECTORIES) {
159
+ await walk(join(projectDir, sub));
160
+ }
161
+ return out;
162
+ }
163
+ function findJsxOpeningTags(file, tagName) {
164
+ const usages = [];
165
+ const contents = file.contents;
166
+ const escaped = tagName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
167
+ const startPattern = new RegExp(`<${escaped}(?=[\\s/>])`, "g");
168
+ let startMatch;
169
+ while ((startMatch = startPattern.exec(contents)) !== null) {
170
+ const startIdx = startMatch.index;
171
+ const tagEndIdx = findJsxOpeningTagEnd(contents, startIdx + startMatch[0].length);
172
+ if (tagEndIdx === -1)
173
+ continue;
174
+ const openingTag = contents.slice(startIdx, tagEndIdx + 1);
175
+ const upto = contents.slice(0, startIdx);
176
+ const line = (upto.match(/\n/g)?.length ?? 0) + 1;
177
+ usages.push({ file, line, openingTag });
178
+ }
179
+ return usages;
180
+ }
181
+ function findJsxOpeningTagEnd(contents, start) {
182
+ let braceDepth = 0;
183
+ let inDouble = false;
184
+ let inSingle = false;
185
+ let inBacktick = false;
186
+ for (let i = start; i < contents.length; i++) {
187
+ const c = contents[i];
188
+ if (inDouble) {
189
+ if (c === '"' && !isEscaped(contents, i))
190
+ inDouble = false;
191
+ continue;
192
+ }
193
+ if (inSingle) {
194
+ if (c === "'" && !isEscaped(contents, i))
195
+ inSingle = false;
196
+ continue;
197
+ }
198
+ if (inBacktick) {
199
+ if (c === "`" && !isEscaped(contents, i))
200
+ inBacktick = false;
201
+ continue;
202
+ }
203
+ if (c === '"') {
204
+ inDouble = true;
205
+ continue;
206
+ }
207
+ if (c === "'") {
208
+ inSingle = true;
209
+ continue;
210
+ }
211
+ if (c === "`") {
212
+ inBacktick = true;
213
+ continue;
214
+ }
215
+ if (c === "{") {
216
+ braceDepth++;
217
+ continue;
218
+ }
219
+ if (c === "}") {
220
+ braceDepth = Math.max(0, braceDepth - 1);
221
+ continue;
222
+ }
223
+ if (braceDepth === 0 && c === ">") {
224
+ return i;
225
+ }
226
+ }
227
+ return -1;
228
+ }
229
+ function isEscaped(contents, pos) {
230
+ let backslashes = 0;
231
+ let p = pos - 1;
232
+ while (p >= 0 && contents[p] === "\\") {
233
+ backslashes++;
234
+ p--;
235
+ }
236
+ return backslashes % 2 === 1;
237
+ }
238
+ function suggestPreflightImageKey(relPath) {
239
+ const noExt = relPath.replace(/\.(tsx|jsx)$/i, "");
240
+ const segments = noExt.split(/[\\/]/).filter((s) => s.length > 0);
241
+ if (segments.length === 0)
242
+ return "camera";
243
+ const file = segments[segments.length - 1];
244
+ const parent = segments.length > 1 ? segments[segments.length - 2] : file;
245
+ const generic = new Set(["page", "index", "screen", "route", "layout"]);
246
+ const stem = generic.has(file.toLowerCase()) ? parent : file;
247
+ const kebab = stem
248
+ .replace(/([a-z0-9])([A-Z])/g, "$1-$2")
249
+ .toLowerCase()
250
+ .replace(/[^a-z0-9]+/g, "-")
251
+ .replace(/^-+|-+$/g, "");
252
+ return kebab.length > 0 ? kebab : "camera";
253
+ }
254
+ function importsCameraView(contents) {
255
+ const re = /import\s*\{([^}]*)\}\s*from\s*["']expo-camera["']/g;
256
+ let match;
257
+ while ((match = re.exec(contents)) !== null) {
258
+ const named = match[1]
259
+ .split(",")
260
+ .map((s) => s.trim().split(/\s+as\s+/)[0].trim());
261
+ if (named.includes("CameraView"))
262
+ return true;
263
+ }
264
+ return false;
265
+ }
266
+ function readImageKeyProp(openingTag) {
267
+ const KEY = "imageKey";
268
+ let braceDepth = 0;
269
+ let inDouble = false;
270
+ let inSingle = false;
271
+ let inBacktick = false;
272
+ for (let i = 0; i < openingTag.length; i++) {
273
+ const c = openingTag[i];
274
+ if (inDouble) {
275
+ if (c === '"' && !isEscaped(openingTag, i))
276
+ inDouble = false;
277
+ continue;
278
+ }
279
+ if (inSingle) {
280
+ if (c === "'" && !isEscaped(openingTag, i))
281
+ inSingle = false;
282
+ continue;
283
+ }
284
+ if (inBacktick) {
285
+ if (c === "`" && !isEscaped(openingTag, i))
286
+ inBacktick = false;
287
+ continue;
288
+ }
289
+ if (c === '"') {
290
+ inDouble = true;
291
+ continue;
292
+ }
293
+ if (c === "'") {
294
+ inSingle = true;
295
+ continue;
296
+ }
297
+ if (c === "`") {
298
+ inBacktick = true;
299
+ continue;
300
+ }
301
+ if (c === "{") {
302
+ braceDepth++;
303
+ continue;
304
+ }
305
+ if (c === "}") {
306
+ braceDepth = Math.max(0, braceDepth - 1);
307
+ continue;
308
+ }
309
+ if (braceDepth !== 0)
310
+ continue;
311
+ if (c !== "i")
312
+ continue;
313
+ if (openingTag.substring(i, i + KEY.length) !== KEY)
314
+ continue;
315
+ const before = i === 0 ? " " : openingTag[i - 1];
316
+ if (/[A-Za-z0-9_$]/.test(before))
317
+ continue;
318
+ const after = openingTag[i + KEY.length] ?? "";
319
+ if (after !== "=" && !/\s/.test(after))
320
+ continue;
321
+ let j = i + KEY.length;
322
+ while (j < openingTag.length && /\s/.test(openingTag[j]))
323
+ j++;
324
+ if (openingTag[j] !== "=") {
325
+ i = j - 1;
326
+ continue;
327
+ }
328
+ j++;
329
+ while (j < openingTag.length && /\s/.test(openingTag[j]))
330
+ j++;
331
+ const q = openingTag[j];
332
+ if (q === '"' || q === "'") {
333
+ let k = j + 1;
334
+ while (k < openingTag.length &&
335
+ !(openingTag[k] === q && !isEscaped(openingTag, k))) {
336
+ k++;
337
+ }
338
+ const raw = openingTag.slice(j + 1, k);
339
+ if (raw.trim().length === 0)
340
+ return { present: false };
341
+ return { present: true, value: raw };
342
+ }
343
+ if (q === "{") {
344
+ return { present: true, value: null };
345
+ }
346
+ return { present: false };
347
+ }
348
+ return { present: false };
349
+ }
350
+ export async function runPreflight(opts) {
351
+ const { screen, apps, autonomy, bundleId, requiredEnv, expectedNetwork, projectDir = process.cwd(), target: targetResolver, excludeRuntimeChecks = false, } = opts;
352
+ const targetInfo = !excludeRuntimeChecks && targetResolver
353
+ ? await targetResolver.resolve().catch(() => null)
354
+ : null;
355
+ const isSimulator = targetInfo?.target === "simulator";
356
+ const registryPromise = syncCameraRegistry(projectDir);
357
+ const registrySyncCheck = registryPromise.then((res) => {
358
+ if (res.writtenOrUnchanged === "missing-dir") {
359
+ return {
360
+ name: "camera-registry-synced",
361
+ ok: true,
362
+ severity: "info",
363
+ message: "Skipped — no .ceraph/camera-images/ directory. " +
364
+ "Drop test images there to enable the camera shim.",
365
+ };
366
+ }
367
+ return {
368
+ name: "camera-registry-synced",
369
+ ok: true,
370
+ severity: "info",
371
+ message: `Camera image registry ${res.writtenOrUnchanged} ` +
372
+ `(${res.registered.length} image${res.registered.length === 1 ? "" : "s"}) ` +
373
+ `at ${relativeRegistryPath(projectDir, res.registryPath)}.`,
374
+ details: {
375
+ registered: res.registered.map((r) => r.key),
376
+ state: res.writtenOrUnchanged,
377
+ },
378
+ };
379
+ }, (err) => ({
380
+ name: "camera-registry-synced",
381
+ ok: false,
382
+ severity: "warning",
383
+ message: `Camera registry sync failed: ${err instanceof Error ? err.message : String(err)}`,
384
+ }));
385
+ const sourceFilesPromise = listSourceFiles(projectDir);
386
+ const unwrappedCameraCheck = safeCheck("camera-views-unwrapped", "warning", async () => {
387
+ const files = await sourceFilesPromise;
388
+ const offenders = [];
389
+ for (const file of files) {
390
+ if (!importsCameraView(file.contents))
391
+ continue;
392
+ const usages = findJsxOpeningTags(file, "CameraView");
393
+ for (const usage of usages) {
394
+ offenders.push(`${usage.file.relPath}:${usage.line}`);
395
+ }
396
+ }
397
+ if (offenders.length === 0) {
398
+ return {
399
+ ok: true,
400
+ severity: "warning",
401
+ message: "No bare <CameraView> usages from expo-camera found in app/src/screens.",
402
+ };
403
+ }
404
+ return {
405
+ ok: false,
406
+ severity: "warning",
407
+ message: `Found ${offenders.length} <CameraView> usage(s) not wrapped by <CeraphCamera>.`,
408
+ remediation: "Replace each <CameraView /> with <CeraphCamera imageKey=\"...\" /> " +
409
+ 'from "@ceraph/react-native-mcp/shim". The shim falls through to ' +
410
+ "expo-camera in production builds.",
411
+ details: { offenders },
412
+ };
413
+ });
414
+ const setupCheck = safeCheck("camera-imageKey-setup", "info", async () => {
415
+ const files = await sourceFilesPromise;
416
+ const uninitializedCameras = [];
417
+ for (const file of files) {
418
+ const usages = findJsxOpeningTags(file, "CeraphCamera");
419
+ for (const usage of usages) {
420
+ const prop = readImageKeyProp(usage.openingTag);
421
+ if (!prop.present) {
422
+ uninitializedCameras.push({
423
+ location: `${usage.file.relPath}:${usage.line}`,
424
+ file: usage.file.relPath,
425
+ line: usage.line,
426
+ suggestedKey: suggestPreflightImageKey(usage.file.relPath),
427
+ });
428
+ }
429
+ }
430
+ }
431
+ if (uninitializedCameras.length === 0) {
432
+ return {
433
+ ok: true,
434
+ severity: "info",
435
+ message: "All <CeraphCamera> components have an explicit imageKey (or use a runtime sentinel).",
436
+ };
437
+ }
438
+ return {
439
+ ok: true,
440
+ severity: "info",
441
+ message: `${uninitializedCameras.length} <CeraphCamera> component${uninitializedCameras.length === 1 ? "" : "s"} ` +
442
+ `awaiting imageKey assignment ` +
443
+ `(${uninitializedCameras.map((c) => c.location).join(", ")}).`,
444
+ remediation: "Ask the user about each camera screen and use " +
445
+ "`ceraph_init_replace_camera` to set the imageKey. Three options per camera: " +
446
+ "(A) imageKey=\"default\" — bundled 1024x1024 black PNG. Right for tests " +
447
+ "that don't care about image content (most form/upload flows). " +
448
+ "(B) imageKey=\"<scenario>\" — a specific test image. Drop the file at " +
449
+ ".ceraph/camera-images/<scenario>.{jpg,png,webp} (use `ceraph_add_camera_image` " +
450
+ "to write it programmatically) and re-run `rn_sync_camera_registry`. " +
451
+ "(C) imageKey=\"@runtime\" — multi-image flows where the planner picks the " +
452
+ "image per-step at runtime; the shim won't lock the camera to any static key.",
453
+ details: { uninitializedCameras },
454
+ };
455
+ });
456
+ const orphanImageKeyCheck = safeCheck("camera-image-key-orphan", "error", async () => {
457
+ const [registry, files] = await Promise.all([
458
+ registryPromise,
459
+ sourceFilesPromise,
460
+ ]);
461
+ const registeredKeys = new Set(registry.registered.map((r) => r.key));
462
+ const orphans = [];
463
+ for (const file of files) {
464
+ const usages = findJsxOpeningTags(file, "CeraphCamera");
465
+ for (const usage of usages) {
466
+ const prop = readImageKeyProp(usage.openingTag);
467
+ if (!prop.present)
468
+ continue;
469
+ if (prop.value === null)
470
+ continue;
471
+ if (prop.value === "default")
472
+ continue;
473
+ if (prop.value.startsWith("@"))
474
+ continue;
475
+ if (!registeredKeys.has(prop.value)) {
476
+ orphans.push({
477
+ location: `${usage.file.relPath}:${usage.line}`,
478
+ key: prop.value,
479
+ });
480
+ }
481
+ }
482
+ }
483
+ if (orphans.length === 0) {
484
+ return {
485
+ ok: true,
486
+ severity: "error",
487
+ message: "All <CeraphCamera imageKey> values resolve to files in .ceraph/camera-images/.",
488
+ };
489
+ }
490
+ const summary = orphans
491
+ .map((o) => `${o.location} (imageKey="${o.key}")`)
492
+ .join("; ");
493
+ return {
494
+ ok: false,
495
+ severity: "error",
496
+ message: `Found ${orphans.length} orphan imageKey reference(s): ${summary}.`,
497
+ remediation: "Add the matching image file to .ceraph/camera-images/ " +
498
+ "(e.g. <key>.jpg) and re-run `rn_sync_camera_registry`, or fix the imageKey prop value.",
499
+ details: { orphans },
500
+ };
501
+ });
502
+ const targetCheck = excludeRuntimeChecks
503
+ ? null
504
+ : targetResolver
505
+ ? Promise.resolve({
506
+ name: "target-selected",
507
+ ok: true,
508
+ severity: "info",
509
+ message: targetInfo
510
+ ? `Target: ${targetInfo.target} (${targetInfo.reason}). ` +
511
+ `WDA base URL: ${targetInfo.baseUrl}` +
512
+ (targetInfo.udid ? `; udid ${targetInfo.udid}` : "")
513
+ : "Target: unknown (resolver failed). Falling back to device defaults.",
514
+ details: targetInfo
515
+ ? {
516
+ target: targetInfo.target,
517
+ reason: targetInfo.reason,
518
+ baseUrl: targetInfo.baseUrl,
519
+ udid: targetInfo.udid,
520
+ preferenceEnv: process.env.CERAPH_TARGET ?? null,
521
+ }
522
+ : undefined,
523
+ })
524
+ : null;
525
+ const wdaRemediationSim = "Simulator WDA needs to be built and launched first. Run " +
526
+ "`rn_wda_start` (requires the `appium-webdriveragent` optional " +
527
+ "dep). The first build can take 60-120s and may require an Xcode " +
528
+ "signing prompt; subsequent starts are fast.";
529
+ const wdaRemediationDevice = "Ensure WebDriverAgent is running on the device (Xcode → run " +
530
+ "WDA target on your iPhone). If the iproxy auto-bridge failed " +
531
+ "above, follow its remediation first.";
532
+ const wdaReady = targetInfo?.wdaReady ?? true;
533
+ const wdaLocation = wdaReady
534
+ ? (targetInfo?.baseUrl ?? "http://localhost:8100")
535
+ : null;
536
+ let iproxyBridgeCheck;
537
+ let wdaCheck;
538
+ if (excludeRuntimeChecks) {
539
+ iproxyBridgeCheck = null;
540
+ wdaCheck = null;
541
+ }
542
+ else if (!isSimulator && opts.iproxyManager) {
543
+ const mgr = opts.iproxyManager;
544
+ const bridged = (async () => {
545
+ let reachable = await screen.isAvailable();
546
+ let bridgeMessage;
547
+ let bridgeOk = true;
548
+ let bridgeRemediation;
549
+ let bridgeDetails;
550
+ if (reachable) {
551
+ bridgeMessage =
552
+ "WebDriverAgent already reachable — skipped iproxy auto-bridge.";
553
+ }
554
+ else {
555
+ const udid = await apps.getUdid().catch(() => null);
556
+ if (!udid) {
557
+ bridgeOk = false;
558
+ bridgeMessage =
559
+ "WDA unreachable and no iOS device detected — cannot auto-bridge.";
560
+ bridgeRemediation =
561
+ "Connect your iPhone via USB-C and trust this Mac, then re-run preflight.";
562
+ }
563
+ else {
564
+ const startRes = await mgr.start(udid);
565
+ if (startRes.ok) {
566
+ reachable = await pollUntil(() => screen.isAvailable(), { intervalMs: 250, timeoutMs: 2500 });
567
+ bridgeMessage = `iproxy bridge started (pid ${startRes.pid}).`;
568
+ bridgeDetails = { pid: startRes.pid, udid };
569
+ }
570
+ else if (startRes.reason === "already-running") {
571
+ reachable = await pollUntil(() => screen.isAvailable(), { intervalMs: 250, timeoutMs: 2500 });
572
+ bridgeMessage = "iproxy bridge already running (external).";
573
+ }
574
+ else if (startRes.reason === "not-installed") {
575
+ bridgeOk = false;
576
+ bridgeMessage =
577
+ "iproxy is not installed — cannot auto-bridge WDA.";
578
+ bridgeRemediation =
579
+ "Install with `brew install libimobiledevice`, then re-run preflight.";
580
+ }
581
+ else {
582
+ bridgeOk = false;
583
+ bridgeMessage =
584
+ `iproxy auto-bridge failed to start: ` +
585
+ `${startRes.stderr?.[0] ?? "unknown error"}.`;
586
+ bridgeRemediation =
587
+ "Run `iproxy 8100 8100 -u <udid>` manually to see the full error.";
588
+ bridgeDetails = { stderr: startRes.stderr };
589
+ }
590
+ }
591
+ }
592
+ const bridge = {
593
+ name: "iproxy-bridge",
594
+ ok: bridgeOk,
595
+ severity: "warning",
596
+ message: bridgeMessage,
597
+ remediation: bridgeRemediation,
598
+ details: bridgeDetails,
599
+ };
600
+ const wda = reachable
601
+ ? {
602
+ name: "wda-reachable",
603
+ ok: true,
604
+ severity: "error",
605
+ message: `WebDriverAgent is reachable at ${wdaLocation ?? "http://localhost:8100"}.`,
606
+ }
607
+ : {
608
+ name: "wda-reachable",
609
+ ok: false,
610
+ severity: "error",
611
+ message: wdaLocation
612
+ ? `WebDriverAgent is not reachable at ${wdaLocation}.`
613
+ : "WebDriverAgent is not ready (simulator selected, no port captured yet).",
614
+ remediation: wdaRemediationDevice,
615
+ };
616
+ return { bridge, wda };
617
+ })().catch((err) => ({
618
+ bridge: {
619
+ name: "iproxy-bridge",
620
+ ok: false,
621
+ severity: "warning",
622
+ message: `iproxy auto-bridge threw: ${err instanceof Error ? err.message : String(err)}`,
623
+ },
624
+ wda: {
625
+ name: "wda-reachable",
626
+ ok: false,
627
+ severity: "error",
628
+ message: `WDA reachability probe threw: ${err instanceof Error ? err.message : String(err)}`,
629
+ },
630
+ }));
631
+ iproxyBridgeCheck = bridged.then((r) => r.bridge);
632
+ wdaCheck = bridged.then((r) => r.wda);
633
+ }
634
+ else {
635
+ iproxyBridgeCheck = Promise.resolve({
636
+ name: "iproxy-bridge",
637
+ ok: true,
638
+ severity: "info",
639
+ message: isSimulator
640
+ ? "Skipped — iproxy is not used on the simulator path."
641
+ : "Skipped — iproxy auto-bridge not enabled for this preflight call.",
642
+ });
643
+ wdaCheck = safeCheck("wda-reachable", "error", async () => {
644
+ const reachable = await screen.isAvailable();
645
+ if (reachable) {
646
+ return {
647
+ ok: true,
648
+ severity: "error",
649
+ message: `WebDriverAgent is reachable at ${wdaLocation ?? "http://localhost:8100"}.`,
650
+ };
651
+ }
652
+ return {
653
+ ok: false,
654
+ severity: "error",
655
+ message: wdaLocation
656
+ ? `WebDriverAgent is not reachable at ${wdaLocation}.`
657
+ : "WebDriverAgent is not ready (simulator selected, no port captured yet).",
658
+ remediation: isSimulator ? wdaRemediationSim : wdaRemediationDevice,
659
+ };
660
+ });
661
+ }
662
+ const udidSeverity = isSimulator ? "warning" : "error";
663
+ const udidCheck = excludeRuntimeChecks
664
+ ? null
665
+ : safeCheck("real-device-connected", udidSeverity, async () => {
666
+ const udid = await apps.getUdid();
667
+ if (udid) {
668
+ return {
669
+ ok: true,
670
+ severity: udidSeverity,
671
+ message: `Real iOS device connected (udid ${udid}).`,
672
+ details: { udid },
673
+ };
674
+ }
675
+ if (isSimulator) {
676
+ return {
677
+ ok: true,
678
+ severity: "info",
679
+ message: "No real iOS device detected — target is simulator, so this is fine.",
680
+ };
681
+ }
682
+ return {
683
+ ok: false,
684
+ severity: udidSeverity,
685
+ message: "No iOS target available.",
686
+ remediation: "v1 ships real-device only. The agent can resolve this by " +
687
+ "calling `rn_wait_for_device` — listens on macOS's usbmuxd " +
688
+ "daemon for sub-second event-driven detection (no polling, no " +
689
+ "timeout) — after telling the user to plug in their iPhone " +
690
+ "via USB-C. Or manually: connect the iPhone + trust this Mac " +
691
+ "(run `xcrun devicectl list devices` to verify) and re-run " +
692
+ "the original tool.",
693
+ };
694
+ });
695
+ const awakeCheck = excludeRuntimeChecks
696
+ ? null
697
+ : safeCheck("device-awake", "error", async () => {
698
+ const res = await autonomy.ensureAwake();
699
+ if (res.awake) {
700
+ return {
701
+ ok: true,
702
+ severity: "error",
703
+ message: `Device is awake (lockState: ${res.lockState}).`,
704
+ details: { lockState: res.lockState },
705
+ };
706
+ }
707
+ return {
708
+ ok: false,
709
+ severity: "error",
710
+ message: `Device is not awake (lockState: ${res.lockState}).`,
711
+ remediation: res.remediation ??
712
+ "Unlock the device manually and set Auto-Lock to Never.",
713
+ details: { lockState: res.lockState },
714
+ };
715
+ });
716
+ const autoLockCheck = excludeRuntimeChecks
717
+ ? null
718
+ : Promise.resolve({
719
+ name: "auto-lock-disabled",
720
+ ok: true,
721
+ severity: "info",
722
+ message: "Verify Settings → Display & Brightness → Auto-Lock is set to " +
723
+ "Never. Otherwise the phone will sleep mid-flow.",
724
+ });
725
+ const appInstalledCheck = excludeRuntimeChecks
726
+ ? null
727
+ : bundleId
728
+ ? safeCheck("app-installed", "error", async () => {
729
+ const res = await apps.listInstalledApps();
730
+ if (!res.success) {
731
+ return {
732
+ ok: false,
733
+ severity: "error",
734
+ message: `Could not list installed apps: ${res.error ?? "unknown error"}`,
735
+ remediation: "Ensure your device is connected and Xcode is installed " +
736
+ "(`xcrun devicectl list devices`).",
737
+ };
738
+ }
739
+ const found = res.apps.find((a) => a.bundleId === bundleId);
740
+ if (found) {
741
+ return {
742
+ ok: true,
743
+ severity: "error",
744
+ message: `App ${bundleId} is installed (${found.name}${found.version ? ` v${found.version}` : ""}).`,
745
+ details: { bundleId, name: found.name, version: found.version },
746
+ };
747
+ }
748
+ return {
749
+ ok: false,
750
+ severity: "error",
751
+ message: `App ${bundleId} is not installed on the device.`,
752
+ remediation: "Build and install the app: `expo run:ios --device <udid>` " +
753
+ "or `npx react-native run-ios --udid <udid>`.",
754
+ details: { bundleId, installedCount: res.apps.length },
755
+ };
756
+ })
757
+ : Promise.resolve({
758
+ name: "app-installed",
759
+ ok: true,
760
+ severity: "info",
761
+ message: "Skipped — no bundleId provided.",
762
+ });
763
+ const envCheck = excludeRuntimeChecks
764
+ ? null
765
+ : requiredEnv && requiredEnv.length > 0
766
+ ? safeCheck("required-env-vars", "error", async () => {
767
+ const missing = requiredEnv.filter((name) => {
768
+ const v = process.env[name];
769
+ return v === undefined || v === "";
770
+ });
771
+ if (missing.length === 0) {
772
+ return {
773
+ ok: true,
774
+ severity: "error",
775
+ message: `All ${requiredEnv.length} required env var(s) present.`,
776
+ details: { required: requiredEnv },
777
+ };
778
+ }
779
+ return {
780
+ ok: false,
781
+ severity: "error",
782
+ message: `Missing env var(s): ${missing.join(", ")}.`,
783
+ remediation: `Set the missing vars before running: ` +
784
+ missing.map((n) => `export ${n}=...`).join("; ") +
785
+ " (or add them to .env.local).",
786
+ details: { missing, required: requiredEnv },
787
+ };
788
+ })
789
+ : Promise.resolve({
790
+ name: "required-env-vars",
791
+ ok: true,
792
+ severity: "info",
793
+ message: "Skipped — no required env vars declared.",
794
+ });
795
+ const networkCheck = excludeRuntimeChecks
796
+ ? null
797
+ : safeCheck("laptop-network", "warning", async () => {
798
+ const [ssid, ip] = await Promise.all([getLaptopSsid(), getLaptopIp()]);
799
+ const details = { ssid, ip };
800
+ if (expectedNetwork && ssid && ssid !== expectedNetwork) {
801
+ return {
802
+ ok: false,
803
+ severity: "warning",
804
+ message: `Laptop is on WiFi '${ssid}' at ${ip ?? "<no IP>"}. Expected '${expectedNetwork}'.`,
805
+ remediation: "Join the same WiFi network as your phone, or update the " +
806
+ "expectedNetwork preflight option.",
807
+ details,
808
+ };
809
+ }
810
+ return {
811
+ ok: true,
812
+ severity: "info",
813
+ message: `Laptop is on WiFi '${ssid ?? "<unknown>"}' at ${ip ?? "<no IP>"}. ` +
814
+ "If you see network timeout errors during the run, verify your " +
815
+ "phone is on the same network.",
816
+ details,
817
+ };
818
+ });
819
+ const interceptorCheck = excludeRuntimeChecks
820
+ ? null
821
+ : Promise.resolve({
822
+ name: "permission-interceptor-ready",
823
+ ok: true,
824
+ severity: "info",
825
+ message: "Permission interceptor middleware is wired. iOS system permission " +
826
+ "dialogs will be auto-accepted during flows (configurable via " +
827
+ "rn_permission_auto_accept).",
828
+ });
829
+ const checks = await Promise.all([
830
+ registrySyncCheck,
831
+ targetCheck,
832
+ iproxyBridgeCheck,
833
+ wdaCheck,
834
+ udidCheck,
835
+ awakeCheck,
836
+ autoLockCheck,
837
+ appInstalledCheck,
838
+ envCheck,
839
+ networkCheck,
840
+ interceptorCheck,
841
+ unwrappedCameraCheck,
842
+ setupCheck,
843
+ orphanImageKeyCheck,
844
+ ].filter((c) => c !== null));
845
+ const ok = !checks.some((c) => c.severity === "error" && !c.ok);
846
+ return { ok, checks };
847
+ }