@ceraph/react-native-mcp 0.2.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.
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Detects when a clean Expo prebuild is needed by comparing
3
+ * current project state against a cached snapshot from the last
4
+ * successful build.
5
+ */
6
+ interface PrebuildCheckResult {
7
+ needsClean: boolean;
8
+ reasons: string[];
9
+ }
10
+ export declare class PrebuildDetector {
11
+ private cacheDir;
12
+ private projectDir;
13
+ private snapshotPath;
14
+ constructor(projectDir: string, cacheDir: string);
15
+ /**
16
+ * Check if a file exists.
17
+ */
18
+ private fileExists;
19
+ /**
20
+ * Read and parse a JSON file, returning null if it doesn't exist or is invalid.
21
+ */
22
+ private readJson;
23
+ /**
24
+ * Compute a simple hash of a file's contents for change detection.
25
+ * Uses a basic string hash rather than crypto to keep it lightweight.
26
+ */
27
+ private hashFile;
28
+ /**
29
+ * Read the current project state for snapshot comparison.
30
+ */
31
+ private getCurrentState;
32
+ /**
33
+ * Load the cached snapshot from the last successful build.
34
+ */
35
+ private loadSnapshot;
36
+ /**
37
+ * Save a snapshot after a successful build.
38
+ */
39
+ saveSnapshot(): Promise<void>;
40
+ /**
41
+ * Diff two dependency maps and return lists of added, removed, and changed entries.
42
+ */
43
+ private diffDependencies;
44
+ /**
45
+ * Check whether a clean prebuild is likely needed.
46
+ */
47
+ check(): Promise<PrebuildCheckResult>;
48
+ }
49
+ export {};
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Detects when a clean Expo prebuild is needed by comparing
3
+ * current project state against a cached snapshot from the last
4
+ * successful build.
5
+ */
6
+ import { readFile, writeFile, mkdir, access } from "node:fs/promises";
7
+ import { join } from "node:path";
8
+ export class PrebuildDetector {
9
+ cacheDir;
10
+ projectDir;
11
+ snapshotPath;
12
+ constructor(projectDir, cacheDir) {
13
+ this.projectDir = projectDir;
14
+ this.cacheDir = cacheDir;
15
+ this.snapshotPath = join(cacheDir, "last-build-snapshot.json");
16
+ }
17
+ /**
18
+ * Check if a file exists.
19
+ */
20
+ async fileExists(path) {
21
+ try {
22
+ await access(path);
23
+ return true;
24
+ }
25
+ catch {
26
+ return false;
27
+ }
28
+ }
29
+ /**
30
+ * Read and parse a JSON file, returning null if it doesn't exist or is invalid.
31
+ */
32
+ async readJson(path) {
33
+ try {
34
+ const content = await readFile(path, "utf-8");
35
+ return JSON.parse(content);
36
+ }
37
+ catch {
38
+ return null;
39
+ }
40
+ }
41
+ /**
42
+ * Compute a simple hash of a file's contents for change detection.
43
+ * Uses a basic string hash rather than crypto to keep it lightweight.
44
+ */
45
+ async hashFile(path) {
46
+ try {
47
+ const content = await readFile(path, "utf-8");
48
+ let hash = 0;
49
+ for (let i = 0; i < content.length; i++) {
50
+ const chr = content.charCodeAt(i);
51
+ hash = (hash << 5) - hash + chr;
52
+ hash |= 0; // Convert to 32-bit integer
53
+ }
54
+ return hash.toString(36);
55
+ }
56
+ catch {
57
+ return "missing";
58
+ }
59
+ }
60
+ /**
61
+ * Read the current project state for snapshot comparison.
62
+ */
63
+ async getCurrentState() {
64
+ const pkgJson = await this.readJson(join(this.projectDir, "package.json"));
65
+ // Try app.json first, fall back to app.config.js/ts content
66
+ let appConfig = await this.readJson(join(this.projectDir, "app.json"));
67
+ if (!appConfig) {
68
+ // For app.config.js/ts, we just hash the file instead of evaluating it
69
+ const configHash = await this.hashFile(join(this.projectDir, "app.config.js"));
70
+ const configTsHash = await this.hashFile(join(this.projectDir, "app.config.ts"));
71
+ appConfig = {
72
+ _fileHash: configHash,
73
+ _tsFileHash: configTsHash,
74
+ };
75
+ }
76
+ const podfileLockHash = await this.hashFile(join(this.projectDir, "ios", "Podfile.lock"));
77
+ return {
78
+ dependencies: (pkgJson?.dependencies ?? {}),
79
+ devDependencies: (pkgJson?.devDependencies ?? {}),
80
+ appConfig: appConfig ?? {},
81
+ podfileLockHash,
82
+ timestamp: new Date().toISOString(),
83
+ };
84
+ }
85
+ /**
86
+ * Load the cached snapshot from the last successful build.
87
+ */
88
+ async loadSnapshot() {
89
+ try {
90
+ const content = await readFile(this.snapshotPath, "utf-8");
91
+ return JSON.parse(content);
92
+ }
93
+ catch {
94
+ return null;
95
+ }
96
+ }
97
+ /**
98
+ * Save a snapshot after a successful build.
99
+ */
100
+ async saveSnapshot() {
101
+ const state = await this.getCurrentState();
102
+ // Ensure cache directory exists
103
+ await mkdir(this.cacheDir, { recursive: true });
104
+ await writeFile(this.snapshotPath, JSON.stringify(state, null, 2), "utf-8");
105
+ }
106
+ /**
107
+ * Diff two dependency maps and return lists of added, removed, and changed entries.
108
+ */
109
+ diffDependencies(oldDeps, newDeps) {
110
+ const added = [];
111
+ const removed = [];
112
+ const changed = [];
113
+ const allKeys = new Set([
114
+ ...Object.keys(oldDeps),
115
+ ...Object.keys(newDeps),
116
+ ]);
117
+ for (const key of allKeys) {
118
+ if (!(key in oldDeps)) {
119
+ added.push(key);
120
+ }
121
+ else if (!(key in newDeps)) {
122
+ removed.push(key);
123
+ }
124
+ else if (oldDeps[key] !== newDeps[key]) {
125
+ changed.push(key);
126
+ }
127
+ }
128
+ return { added, removed, changed };
129
+ }
130
+ /**
131
+ * Check whether a clean prebuild is likely needed.
132
+ */
133
+ async check() {
134
+ const reasons = [];
135
+ // Check if ios/ directory exists at all
136
+ const iosExists = await this.fileExists(join(this.projectDir, "ios"));
137
+ if (!iosExists) {
138
+ return {
139
+ needsClean: true,
140
+ reasons: [
141
+ "ios/ directory does not exist. Run `npx expo prebuild` to generate native projects.",
142
+ ],
143
+ };
144
+ }
145
+ // Load the last build snapshot
146
+ const snapshot = await this.loadSnapshot();
147
+ if (!snapshot) {
148
+ return {
149
+ needsClean: true,
150
+ reasons: [
151
+ "No previous build snapshot found. Run a build to establish a baseline. " +
152
+ "A clean prebuild is recommended for the first build.",
153
+ ],
154
+ };
155
+ }
156
+ // Get current state
157
+ const current = await this.getCurrentState();
158
+ // Compare dependencies
159
+ const depDiff = this.diffDependencies(snapshot.dependencies, current.dependencies);
160
+ if (depDiff.added.length > 0) {
161
+ reasons.push(`New dependencies added: ${depDiff.added.join(", ")}. ` +
162
+ "Native modules may need linking.");
163
+ }
164
+ if (depDiff.removed.length > 0) {
165
+ reasons.push(`Dependencies removed: ${depDiff.removed.join(", ")}. ` +
166
+ "Stale native modules may cause build errors.");
167
+ }
168
+ if (depDiff.changed.length > 0) {
169
+ // Only flag native-related version changes as needing clean prebuild
170
+ const nativeIndicators = [
171
+ "react-native",
172
+ "expo",
173
+ "@react-native",
174
+ "react-native-",
175
+ ];
176
+ const nativeChanges = depDiff.changed.filter((dep) => nativeIndicators.some((indicator) => dep.includes(indicator)));
177
+ if (nativeChanges.length > 0) {
178
+ reasons.push(`Native dependency versions changed: ${nativeChanges.join(", ")}. ` +
179
+ "Clean prebuild recommended.");
180
+ }
181
+ }
182
+ // Compare devDependencies
183
+ const devDepDiff = this.diffDependencies(snapshot.devDependencies, current.devDependencies);
184
+ if (devDepDiff.added.length > 0 || devDepDiff.removed.length > 0) {
185
+ const nativeDevDeps = [
186
+ ...devDepDiff.added,
187
+ ...devDepDiff.removed,
188
+ ].filter((dep) => dep.includes("react-native") ||
189
+ dep.includes("expo") ||
190
+ dep.includes("@react-native"));
191
+ if (nativeDevDeps.length > 0) {
192
+ reasons.push(`Native dev dependencies changed: ${nativeDevDeps.join(", ")}.`);
193
+ }
194
+ }
195
+ // Compare app config
196
+ const configChanged = JSON.stringify(snapshot.appConfig) !==
197
+ JSON.stringify(current.appConfig);
198
+ if (configChanged) {
199
+ reasons.push("app.json / app.config.js has changed since last build. " +
200
+ "Expo plugins or native config may have changed.");
201
+ }
202
+ // Compare Podfile.lock hash
203
+ if (current.podfileLockHash === "missing") {
204
+ reasons.push("ios/Podfile.lock is missing. Pods may need to be installed.");
205
+ }
206
+ else if (snapshot.podfileLockHash !== current.podfileLockHash) {
207
+ reasons.push("ios/Podfile.lock has changed since last build. " +
208
+ "Pod dependencies may be out of sync.");
209
+ }
210
+ return {
211
+ needsClean: reasons.length > 0,
212
+ reasons,
213
+ };
214
+ }
215
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * WebDriverAgent client with pixel ratio correction.
3
+ *
4
+ * Talks to the WDA HTTP server running on localhost:8100 to perform
5
+ * taps and element lookups on a connected iOS device.
6
+ */
7
+ export interface TapResult {
8
+ success: boolean;
9
+ tappedAt: {
10
+ x: number;
11
+ y: number;
12
+ };
13
+ pixelRatio: number;
14
+ correction: string;
15
+ error?: string;
16
+ }
17
+ export interface ElementInfo {
18
+ type: string;
19
+ label: string;
20
+ bounds: {
21
+ x: number;
22
+ y: number;
23
+ width: number;
24
+ height: number;
25
+ };
26
+ }
27
+ export interface FindTapResult {
28
+ success: boolean;
29
+ element?: ElementInfo;
30
+ tappedAt?: {
31
+ x: number;
32
+ y: number;
33
+ };
34
+ availableElements?: Array<{
35
+ type: string;
36
+ label: string;
37
+ text: string;
38
+ }>;
39
+ error?: string;
40
+ }
41
+ interface ElementQuery {
42
+ text?: string;
43
+ accessibilityLabel?: string;
44
+ type?: string;
45
+ index?: number;
46
+ }
47
+ export declare class ScreenManager {
48
+ private sessionId;
49
+ private pixelRatio;
50
+ /**
51
+ * Check whether WebDriverAgent is reachable.
52
+ */
53
+ isAvailable(): Promise<boolean>;
54
+ /**
55
+ * Get or create a WDA session. Sessions are cached and revalidated.
56
+ */
57
+ ensureSession(): Promise<string>;
58
+ /**
59
+ * Determine the pixel ratio between screenshot coordinates and
60
+ * device logical coordinates.
61
+ *
62
+ * Screenshot images are in physical pixels; WDA taps use logical points.
63
+ * The ratio is typically 2 (for @2x Retina) or 3 (for @3x).
64
+ */
65
+ getPixelRatio(): Promise<number>;
66
+ /**
67
+ * Tap at the given coordinates.
68
+ *
69
+ * When `fromScreenshot` is true (the default), the coordinates are
70
+ * assumed to come from a screenshot image and are divided by the
71
+ * pixel ratio to convert to logical device points.
72
+ */
73
+ tap(x: number, y: number, fromScreenshot?: boolean): Promise<TapResult>;
74
+ /**
75
+ * Find an element in the UI tree and tap its center.
76
+ */
77
+ findAndTap(query: ElementQuery): Promise<FindTapResult>;
78
+ /**
79
+ * Recursively search the WDA element tree for elements matching the query.
80
+ */
81
+ private searchElements;
82
+ /**
83
+ * Check whether a single element matches the given query.
84
+ */
85
+ private elementMatches;
86
+ /**
87
+ * Collect a summary of visible elements for debugging (when no match found).
88
+ */
89
+ private collectVisibleElements;
90
+ /**
91
+ * Invalidate cached session and pixel ratio (e.g., after app restart).
92
+ */
93
+ reset(): void;
94
+ }
95
+ export {};